diff --git a/apps/web/pages/settings/admin/impersonation.tsx b/apps/web/pages/settings/admin/impersonation.tsx index 6bd524e77a..a8e6cb3b86 100644 --- a/apps/web/pages/settings/admin/impersonation.tsx +++ b/apps/web/pages/settings/admin/impersonation.tsx @@ -31,8 +31,11 @@ function AdminView() { ref={usernameRef} hint={t("impersonate_user_tip")} defaultValue={undefined} + data-testid="admin-impersonation-input" /> - + diff --git a/apps/web/playwright/fixtures/users.ts b/apps/web/playwright/fixtures/users.ts index f6b0992ffe..4d6788330b 100644 --- a/apps/web/playwright/fixtures/users.ts +++ b/apps/web/playwright/fixtures/users.ts @@ -563,6 +563,8 @@ type CustomUserOptsKeys = | "name" | "email" | "organizationId" + | "twoFactorEnabled" + | "disableImpersonation" | "role"; type CustomUserOpts = Partial> & { timeZone?: TimeZoneEnum; @@ -594,6 +596,8 @@ const createUser = ( timeZone: opts?.timeZone ?? TimeZoneEnum.UK, locale: opts?.locale ?? "en", role: opts?.role ?? "USER", + twoFactorEnabled: opts?.twoFactorEnabled ?? false, + disableImpersonation: opts?.disableImpersonation ?? false, ...getOrganizationRelatedProps({ organizationId: opts?.organizationId, role: opts?.roleInOrganization }), schedules: opts?.completedOnboarding ?? true diff --git a/apps/web/playwright/impersonation.e2e.ts b/apps/web/playwright/impersonation.e2e.ts new file mode 100644 index 0000000000..a1424f8b9a --- /dev/null +++ b/apps/web/playwright/impersonation.e2e.ts @@ -0,0 +1,50 @@ +import { expect } from "@playwright/test"; + +import { test } from "./lib/fixtures"; + +test.describe.configure({ mode: "parallel" }); + +test.describe("Users can impersonate", async () => { + test.afterAll(async ({ users }) => { + await users.deleteAll(); + }); + test("App Admin can impersonate users with impersonation enabled", async ({ page, users }) => { + // log in trail user + const user = await users.create({ + role: "ADMIN", + password: "ADMINadmin2022!", + }); + + const userToImpersonate = await users.create({ disableImpersonation: false }); + + await user.apiLogin(); + await page.waitForLoadState(); + + await page.goto("/settings/admin/impersonation"); + await page.waitForLoadState(); + const adminInput = page.getByTestId("admin-impersonation-input"); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore the username does exist + await adminInput.fill(userToImpersonate.username); + await page.getByTestId("impersonation-submit").click(); + + // // Wait for sign in to complete + await page.waitForURL("/settings/my-account/profile"); + + const stopImpersonatingButton = page.getByTestId("stop-impersonating-button"); + + const impersonatedUsernameInput = page.locator("input[name='username']"); + const impersonatedUser = await impersonatedUsernameInput.inputValue(); + + await expect(stopImpersonatingButton).toBeVisible(); + await expect(impersonatedUser).toBe(userToImpersonate.username); + + await stopImpersonatingButton.click(); + + await page.waitForLoadState("networkidle"); + // Return to user + const ogUser = await impersonatedUsernameInput.inputValue(); + + expect(ogUser).toBe(user.username); + }); +}); diff --git a/packages/features/auth/lib/getServerSession.ts b/packages/features/auth/lib/getServerSession.ts index 6b7809f653..f883554efc 100644 --- a/packages/features/auth/lib/getServerSession.ts +++ b/packages/features/auth/lib/getServerSession.ts @@ -72,13 +72,30 @@ export async function getServerSession(options: { email_verified: user.emailVerified !== null, role: user.role, image: `${CAL_URL}/${user.username}/avatar.png`, - impersonatedByUID: token.impersonatedByUID ?? undefined, belongsToActiveTeam: token.belongsToActiveTeam, org: token.org, locale: user.locale ?? undefined, }, }; + if (token?.impersonatedBy?.id) { + const impersonatedByUser = await prisma.user.findUnique({ + where: { + id: token.impersonatedBy.id, + }, + select: { + id: true, + role: true, + }, + }); + if (impersonatedByUser) { + session.user.impersonatedBy = { + id: impersonatedByUser?.id, + role: impersonatedByUser.role, + }; + } + } + CACHE.set(JSON.stringify(token), session); return session; diff --git a/packages/features/auth/lib/next-auth-options.ts b/packages/features/auth/lib/next-auth-options.ts index 277e2aa050..0cf2530174 100644 --- a/packages/features/auth/lib/next-auth-options.ts +++ b/packages/features/auth/lib/next-auth-options.ts @@ -508,7 +508,7 @@ export const AUTH_OPTIONS: AuthOptions = { username: user.username, email: user.email, role: user.role, - impersonatedByUID: user?.impersonatedByUID, + impersonatedBy: user.impersonatedBy, belongsToActiveTeam: user?.belongsToActiveTeam, org: user?.org, locale: user?.locale, @@ -547,7 +547,7 @@ export const AUTH_OPTIONS: AuthOptions = { username: existingUser.username, email: existingUser.email, role: existingUser.role, - impersonatedByUID: token.impersonatedByUID as number, + impersonatedBy: token.impersonatedBy, belongsToActiveTeam: token?.belongsToActiveTeam as boolean, org: token?.org, locale: existingUser.locale, @@ -567,7 +567,7 @@ export const AUTH_OPTIONS: AuthOptions = { name: token.name, username: token.username as string, role: token.role as UserPermissionRole, - impersonatedByUID: token.impersonatedByUID as number, + impersonatedBy: token.impersonatedBy, belongsToActiveTeam: token?.belongsToActiveTeam as boolean, org: token?.org, locale: token.locale, diff --git a/packages/features/ee/impersonation/components/ImpersonatingBanner.tsx b/packages/features/ee/impersonation/components/ImpersonatingBanner.tsx index f4ea44eeac..78a4124452 100644 --- a/packages/features/ee/impersonation/components/ImpersonatingBanner.tsx +++ b/packages/features/ee/impersonation/components/ImpersonatingBanner.tsx @@ -1,4 +1,5 @@ import type { SessionContextValue } from "next-auth/react"; +import { signIn } from "next-auth/react"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { TopBanner } from "@calcom/ui"; @@ -8,7 +9,10 @@ export type ImpersonatingBannerProps = { data: SessionContextValue["data"] }; function ImpersonatingBanner({ data }: ImpersonatingBannerProps) { const { t } = useLocale(); - if (!data?.user.impersonatedByUID) return null; + if (!data?.user.impersonatedBy) return null; + const returnToId = data.user.impersonatedBy.id; + + const canReturnToSelf = data.user.impersonatedBy.role == "ADMIN" || data.user?.org?.id; return ( <> @@ -16,9 +20,21 @@ function ImpersonatingBanner({ data }: ImpersonatingBannerProps) { text={t("impersonating_user_warning", { user: data.user.username })} variant="warning" actions={ - - {t("impersonating_stop_instructions")} - + canReturnToSelf ? ( +
{ + e.preventDefault(); + signIn("impersonation-auth", { returnToId }); + }}> + +
+ ) : ( + + {t("impersonating_stop_instructions")} + + ) } /> diff --git a/packages/features/ee/impersonation/lib/ImpersonationProvider.test.ts b/packages/features/ee/impersonation/lib/ImpersonationProvider.test.ts index 66d517e648..1c246947b8 100644 --- a/packages/features/ee/impersonation/lib/ImpersonationProvider.test.ts +++ b/packages/features/ee/impersonation/lib/ImpersonationProvider.test.ts @@ -7,7 +7,7 @@ import { parseTeamId, checkSelfImpersonation, checkUserIdentifier, - checkPermission, + checkGlobalPermission, } from "./ImpersonationProvider"; const session: Session = { @@ -65,17 +65,17 @@ describe("checkUserIdentifier", () => { describe("checkPermission", () => { it("should throw an error if the user is not an admin and team impersonation is disabled", () => { process.env.NEXT_PUBLIC_TEAM_IMPERSONATION = "false"; - expect(() => checkPermission(session)).toThrow(); + expect(() => checkGlobalPermission(session)).toThrow(); }); it("should not throw an error if the user is an admin and team impersonation is disabled", () => { const modifiedSession = { ...session, user: { ...session.user, role: UserPermissionRole.ADMIN } }; process.env.NEXT_PUBLIC_TEAM_IMPERSONATION = "false"; - expect(() => checkPermission(modifiedSession)).not.toThrow(); + expect(() => checkGlobalPermission(modifiedSession)).not.toThrow(); }); it("should not throw an error if the user is not an admin but team impersonation is enabled", () => { process.env.NEXT_PUBLIC_TEAM_IMPERSONATION = "true"; - expect(() => checkPermission(session)).not.toThrow(); + expect(() => checkGlobalPermission(session)).not.toThrow(); }); }); diff --git a/packages/features/ee/impersonation/lib/ImpersonationProvider.ts b/packages/features/ee/impersonation/lib/ImpersonationProvider.ts index 21cd67bc44..9e2837c8e6 100644 --- a/packages/features/ee/impersonation/lib/ImpersonationProvider.ts +++ b/packages/features/ee/impersonation/lib/ImpersonationProvider.ts @@ -14,7 +14,8 @@ const teamIdschema = z.object({ const auditAndReturnNextUser = async ( impersonatedUser: Pick, impersonatedByUID: number, - hasTeam?: boolean + hasTeam?: boolean, + isReturningToSelf?: boolean ) => { // Log impersonations for audit purposes await prisma.impersonations.create({ @@ -38,16 +39,36 @@ const auditAndReturnNextUser = async ( email: impersonatedUser.email, name: impersonatedUser.name, role: impersonatedUser.role, - impersonatedByUID, belongsToActiveTeam: hasTeam, organizationId: impersonatedUser.organizationId, locale: impersonatedUser.locale, }; + if (!isReturningToSelf) { + const impersonatedByUser = await prisma.user.findUnique({ + where: { + id: impersonatedByUID, + }, + select: { + id: true, + role: true, + }, + }); + if (!impersonatedByUser) throw new Error("This user does not exist."); + + return { + ...obj, + impersonatedBy: { + id: impersonatedByUser?.id, + role: impersonatedByUser?.role, + }, + }; + } + return obj; }; -type Credentials = Record<"username" | "teamId", string> | undefined; +type Credentials = Record<"username" | "teamId" | "returnToId", string> | undefined; export function parseTeamId(creds: Partial) { return creds?.teamId ? teamIdschema.parse({ teamId: creds.teamId }).teamId : undefined; @@ -60,10 +81,13 @@ export function checkSelfImpersonation(session: Session | null, creds: Partial) { - if (!creds?.username) throw new Error("User identifier must be present"); + if (!creds?.username) { + if (creds?.returnToId) return; + throw new Error("User identifier must be present"); + } } -export function checkPermission(session: Session | null) { +export function checkGlobalPermission(session: Session | null) { if ( (session?.user.role !== "ADMIN" && process.env.NEXT_PUBLIC_TEAM_IMPERSONATION === "false") || !session?.user @@ -133,6 +157,60 @@ async function getImpersonatedUser({ return impersonatedUser; } +async function isReturningToSelf({ session, creds }: { session: Session | null; creds: Credentials | null }) { + const impersonatedByUID = session?.user.impersonatedBy?.id; + if (!impersonatedByUID || !creds?.returnToId) return; + const returnToId = parseInt(creds?.returnToId, 10); + + // Ensure session impersonatedUID + the returnToId is the same so we cant take over a random account + if (impersonatedByUID !== returnToId) return; + + const returningUser = await prisma.user.findUnique({ + where: { + id: returnToId, + }, + select: { + id: true, + username: true, + email: true, + name: true, + role: true, + organizationId: true, + locale: true, + teams: { + where: { + accepted: true, // Ensure they are apart of the team and not just invited. + }, + select: { + teamId: true, + disableImpersonation: true, + role: true, + }, + }, + }, + }); + + if (returningUser) { + // Skip for none org users + if (returningUser.role !== "ADMIN" && !returningUser.organizationId) return; + + const hasTeams = returningUser.teams.length >= 1; + return { + user: { + id: returningUser.id, + email: returningUser.email, + locale: returningUser.locale, + name: returningUser.name, + organizationId: returningUser.organizationId, + role: returningUser.role, + username: returningUser.username, + }, + impersonatedByUID, + hasTeams, + }; + } +} + const ImpersonationProvider = CredentialsProvider({ id: "impersonation-auth", name: "Impersonation", @@ -140,6 +218,7 @@ const ImpersonationProvider = CredentialsProvider({ credentials: { username: { type: "text" }, teamId: { type: "text" }, + returnToId: { type: "text" }, }, async authorize(creds, req) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -148,7 +227,19 @@ const ImpersonationProvider = CredentialsProvider({ const teamId = parseTeamId(creds); checkSelfImpersonation(session, creds); checkUserIdentifier(creds); - checkPermission(session); + + // Returning to target and UID is self without having to do perm checks. + const returnToUser = await isReturningToSelf({ session, creds }); + if (returnToUser) { + return auditAndReturnNextUser( + returnToUser.user, + returnToUser.impersonatedByUID, + returnToUser.hasTeams, + true + ); + } + + checkGlobalPermission(session); const impersonatedUser = await getImpersonatedUser({ session, teamId, creds }); diff --git a/packages/features/settings/TimezoneChangeDialog.tsx b/packages/features/settings/TimezoneChangeDialog.tsx index 0b2193799f..cf3504c329 100644 --- a/packages/features/settings/TimezoneChangeDialog.tsx +++ b/packages/features/settings/TimezoneChangeDialog.tsx @@ -56,7 +56,7 @@ export default function TimezoneChangeDialog() { const { data } = useSession(); - if (data?.user.impersonatedByUID) return null; + if (data?.user.impersonatedBy) return null; const ONE_DAY = 60 * 60 * 24; // 1 day in seconds (60 seconds * 60 minutes * 24 hours) const THREE_MONTHS = ONE_DAY * 90; // 90 days in seconds (90 days * 1 day in seconds) diff --git a/packages/features/shell/Shell.tsx b/packages/features/shell/Shell.tsx index 54bf706657..ed9c220396 100644 --- a/packages/features/shell/Shell.tsx +++ b/packages/features/shell/Shell.tsx @@ -200,7 +200,7 @@ const useBanners = () => { if (isLoading || !userSession) return null; const isUserInactiveAdmin = userSession?.user.role === "INACTIVE_ADMIN"; - const userImpersonatedByUID = userSession?.user.impersonatedByUID; + const userImpersonatedByUID = userSession?.user.impersonatedBy?.id; const userSessionBanners = { adminPasswordBanner: isUserInactiveAdmin ? userSession : null, diff --git a/packages/types/next-auth.d.ts b/packages/types/next-auth.d.ts index d577403a01..706e977416 100644 --- a/packages/types/next-auth.d.ts +++ b/packages/types/next-auth.d.ts @@ -14,7 +14,10 @@ declare module "next-auth" { id: PrismaUser["id"]; emailVerified?: PrismaUser["emailVerified"]; email_verified?: boolean; - impersonatedByUID?: number; + impersonatedBy?: { + id: number; + role: PrismaUser["role"]; + }; belongsToActiveTeam?: boolean; org?: { id: number; @@ -36,7 +39,10 @@ declare module "next-auth/jwt" { username?: string | null; email?: string | null; role?: UserPermissionRole | "INACTIVE_ADMIN" | null; - impersonatedByUID?: number | null; + impersonatedBy?: { + id: number; + role: PrismaUser["role"]; + }; belongsToActiveTeam?: boolean; org?: { id: number;