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
parent
6197ae25c6
commit
1421b9c0af
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>;
|
||||
};
|
|
@ -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;
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 New Issue