Feat/impersonate users (#2503)

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: zomars <zomars@me.com>
pull/2674/head
sean-brydon 2022-04-26 09:48:17 +01:00 committed by Omar López
parent 6197ae25c6
commit 1421b9c0af
13 changed files with 285 additions and 34 deletions

View File

@ -1,49 +1,52 @@
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}>
<a
className={classNames(
isCurrent
? "border-neutral-900 text-neutral-900"
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700",
"group inline-flex items-center border-b-2 py-4 px-1 text-sm font-medium"
)}
aria-current={isCurrent ? "page" : undefined}>
{tab.icon && (
<tab.icon
className={classNames(
isCurrent ? "text-neutral-900" : "text-gray-400 group-hover:text-gray-500",
"-ml-0.5 hidden h-5 w-5 ltr:mr-2 rtl:ml-2 sm:inline-block"
)}
aria-hidden="true"
/>
)}
<span>{tab.name}</span>
</a>
</Link>
<Component key={tab.name}>
<Link href={tab.href} {...linkProps}>
<a
className={classNames(
isCurrent
? "border-neutral-900 text-neutral-900"
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700",
"group inline-flex items-center border-b-2 py-4 px-1 text-sm font-medium"
)}
aria-current={isCurrent ? "page" : undefined}>
{tab.icon && (
<tab.icon
className={classNames(
isCurrent ? "text-neutral-900" : "text-gray-400 group-hover:text-gray-500",
"-ml-0.5 hidden h-5 w-5 ltr:mr-2 rtl:ml-2 sm:inline-block"
)}
aria-hidden="true"
/>
)}
<span>{tab.name}</span>
</a>
</Link>
</Component>
);
})}
</nav>

View File

@ -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 (

View File

@ -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

View 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>;
};

View 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;

View 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;

View File

@ -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;

View 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: {} };
};

View File

@ -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>."
}

View File

@ -8,7 +8,7 @@
"jsx": "preserve",
"paths": {
"@lib/*": ["../../../apps/web/lib/*"]
},
}
},
"include": ["."],
"exclude": ["dist", "build", "node_modules", "test-cal.tsx"]

View File

@ -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;

View File

@ -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

View File

@ -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