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 ? (
+
+ ) : (
+
+ {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;