Team Impersonation (#3450)
* Team Impersonation * Refactor - Disable Imeprsonate button * Change copy * Add .env toggle * Fix eslint * Update role selection Co-authored-by: Omar López <zomars@me.com> * New 'admin' seed user, improve flow ImpersonationProvider * Fix impersonation string * Adds fullstop Co-authored-by: Sean Brydon <seanbrydon.me@gmail.ocm> Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> Co-authored-by: Omar López <zomars@me.com> Co-authored-by: Alex van Andel <me@alexvanandel.com> Co-authored-by: Peer Richelsen <peeroke@gmail.com>pull/3452/head^2
parent
471420c1d4
commit
d3fcb8bf7d
|
@ -127,3 +127,6 @@ EMAIL_SERVER_PASSWORD='<office365_password>'
|
|||
## @see https://support.google.com/accounts/answer/185833
|
||||
# EMAIL_SERVER_PASSWORD='<gmail_app_password>'
|
||||
# **********************************************************************************************************
|
||||
|
||||
# Set the following value to true if you wish to enable Team Impersonation
|
||||
NEXT_PUBLIC_TEAM_IMPERSONATION=false
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import Button from "@calcom/ui/Button";
|
||||
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import Badge from "@components/ui/Badge";
|
||||
|
||||
const DisableTeamImpersonation = ({ teamId, memberId }: { teamId: number; memberId: number }) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const query = trpc.useQuery(["viewer.teams.getMembershipbyUser", { teamId, memberId }]);
|
||||
|
||||
const mutation = trpc.useMutation("viewer.teams.updateMembership", {
|
||||
onSuccess: async () => {
|
||||
showToast(t("your_user_profile_updated_successfully"), "success");
|
||||
await utils.invalidateQueries(["viewer.teams.getMembershipbyUser"]);
|
||||
},
|
||||
async onSettled() {
|
||||
await utils.invalidateQueries(["viewer.public.i18n"]);
|
||||
},
|
||||
});
|
||||
if (query.isLoading) return <></>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className="font-cal mt-7 pb-4 text-xl leading-6 text-gray-900">{t("settings")}</h3>
|
||||
<div className="-mx-0 rounded-sm border border-neutral-200 bg-white px-4 pb-4 sm:px-6">
|
||||
<div className="flex flex-col justify-between pt-4 sm:flex-row">
|
||||
<div>
|
||||
<div className="flex flex-row items-center">
|
||||
<h2 className="font-cal font-bold leading-6 text-gray-900">
|
||||
{t("user_impersonation_heading")}
|
||||
</h2>
|
||||
<Badge
|
||||
className="ml-2 text-xs"
|
||||
variant={!query.data?.disableImpersonation ? "success" : "gray"}>
|
||||
{!query.data?.disableImpersonation ? t("enabled") : t("disabled")}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-gray-700">{t("team_impersonation_description")}</p>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-0 sm:self-center">
|
||||
<Button
|
||||
type="submit"
|
||||
color="secondary"
|
||||
onClick={() =>
|
||||
!query.data?.disableImpersonation
|
||||
? mutation.mutate({ teamId, memberId, disableImpersonation: true })
|
||||
: mutation.mutate({ teamId, memberId, disableImpersonation: false })
|
||||
}>
|
||||
{!query.data?.disableImpersonation ? t("disable") : t("enable")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DisableTeamImpersonation;
|
|
@ -1,6 +1,7 @@
|
|||
import { PencilIcon, UserRemoveIcon } from "@heroicons/react/outline";
|
||||
import { LockClosedIcon, PencilIcon, UserRemoveIcon } from "@heroicons/react/outline";
|
||||
import { ClockIcon, DotsHorizontalIcon, ExternalLinkIcon } from "@heroicons/react/solid";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { signIn } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
|
@ -39,6 +40,7 @@ export default function MemberListItem(props: Props) {
|
|||
const utils = trpc.useContext();
|
||||
const [showChangeMemberRoleModal, setShowChangeMemberRoleModal] = useState(false);
|
||||
const [showTeamAvailabilityModal, setShowTeamAvailabilityModal] = useState(false);
|
||||
const [showImpersonateModal, setShowImpersonateModal] = useState(false);
|
||||
|
||||
const removeMemberMutation = trpc.useMutation("viewer.teams.removeMember", {
|
||||
async onSuccess() {
|
||||
|
@ -147,6 +149,24 @@ export default function MemberListItem(props: Props) {
|
|||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
{/* Only show impersonate box if - The user has impersonation enabled,
|
||||
They have accepted the team invite, and it is enabled for this instance */}
|
||||
{!props.member.disableImpersonation &&
|
||||
props.member.accepted &&
|
||||
process.env.NEXT_PUBLIC_TEAM_IMPERSONATION === "true" && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<Button
|
||||
onClick={() => setShowImpersonateModal(true)}
|
||||
color="minimal"
|
||||
StartIcon={LockClosedIcon}
|
||||
className="w-full flex-shrink-0 font-normal">
|
||||
{t("impersonate")}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
|
@ -185,6 +205,39 @@ export default function MemberListItem(props: Props) {
|
|||
onExit={() => setShowChangeMemberRoleModal(false)}
|
||||
/>
|
||||
)}
|
||||
{showImpersonateModal && props.member.username && (
|
||||
<ModalContainer isOpen={showImpersonateModal} onExit={() => setShowImpersonateModal(false)}>
|
||||
<>
|
||||
<div className="mb-4 sm:flex sm:items-start">
|
||||
<div className="text-center sm:text-left">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
|
||||
{t("impersonate")}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
await signIn("impersonation-auth", {
|
||||
username: props.member.username,
|
||||
teamId: props.team.id,
|
||||
});
|
||||
}}>
|
||||
<p className="mt-2 text-sm text-gray-500" id="email-description">
|
||||
{t("impersonate_user_tip")}
|
||||
</p>
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<Button type="submit" color="primary" className="ltr:ml-2 rtl:mr-2">
|
||||
{t("impersonate")}
|
||||
</Button>
|
||||
<Button type="button" color="secondary" onClick={() => setShowImpersonateModal(false)}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
</ModalContainer>
|
||||
)}
|
||||
{showTeamAvailabilityModal && (
|
||||
<ModalContainer
|
||||
wide
|
||||
|
|
|
@ -1,66 +1,136 @@
|
|||
import { User } from "@prisma/client";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
import { getSession } from "next-auth/react";
|
||||
|
||||
import { asNumberOrThrow } from "@lib/asStringOrNull";
|
||||
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) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @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");
|
||||
}
|
||||
|
||||
if (user.disableImpersonation) {
|
||||
throw new Error("This user has disabled Impersonation.");
|
||||
}
|
||||
|
||||
const auditAndReturnNextUser = async (
|
||||
impersonatedUser: Pick<User, "id" | "username" | "email" | "name" | "role">,
|
||||
impersonatedByUID: number
|
||||
) => {
|
||||
// Log impersonations for audit purposes
|
||||
await prisma.impersonations.create({
|
||||
data: {
|
||||
impersonatedBy: {
|
||||
connect: {
|
||||
id: session.user.id,
|
||||
id: impersonatedByUID,
|
||||
},
|
||||
},
|
||||
impersonatedUser: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
id: impersonatedUser.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const obj = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
impersonatedByUID: session?.user.id,
|
||||
id: impersonatedUser.id,
|
||||
username: impersonatedUser.username,
|
||||
email: impersonatedUser.email,
|
||||
name: impersonatedUser.name,
|
||||
role: impersonatedUser.role,
|
||||
impersonatedByUID,
|
||||
};
|
||||
|
||||
return obj;
|
||||
};
|
||||
|
||||
const ImpersonationProvider = CredentialsProvider({
|
||||
id: "impersonation-auth",
|
||||
name: "Impersonation",
|
||||
type: "credentials",
|
||||
credentials: {
|
||||
username: { type: "text" },
|
||||
teamId: { type: "text" },
|
||||
},
|
||||
async authorize(creds, req) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore need to figure out how to correctly type this
|
||||
const session = await getSession({ req });
|
||||
const teamId = creds?.teamId ? asNumberOrThrow(creds.teamId) : undefined;
|
||||
|
||||
if (session?.user.username === creds?.username) {
|
||||
throw new Error("You cannot impersonate yourself.");
|
||||
}
|
||||
|
||||
if (!creds?.username) throw new Error("Username must be present");
|
||||
// If you are an ADMIN we return way before team impersonation logic is executed, so NEXT_PUBLIC_TEAM_IMPERSONATION certainly true
|
||||
if (session?.user.role !== "ADMIN" && process.env.NEXT_PUBLIC_TEAM_IMPERSONATION === "false") {
|
||||
throw new Error("You do not have permission to do this.");
|
||||
}
|
||||
|
||||
// Get user who is being impersonated
|
||||
const impersonatedUser = await prisma.user.findUnique({
|
||||
where: {
|
||||
username: creds?.username,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
role: true,
|
||||
name: true,
|
||||
email: true,
|
||||
disableImpersonation: true,
|
||||
teams: {
|
||||
where: {
|
||||
disableImpersonation: false, // Ensure they have impersonation enabled
|
||||
accepted: true, // Ensure they are apart of the team and not just invited.
|
||||
team: {
|
||||
id: teamId, // Bring back only the right team
|
||||
},
|
||||
},
|
||||
select: {
|
||||
teamId: true,
|
||||
disableImpersonation: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Check if impersonating is allowed for this user
|
||||
if (!impersonatedUser) {
|
||||
throw new Error("This user does not exist");
|
||||
}
|
||||
|
||||
if (session?.user.role === "ADMIN") {
|
||||
if (impersonatedUser.disableImpersonation) {
|
||||
throw new Error("This user has disabled Impersonation.");
|
||||
}
|
||||
return auditAndReturnNextUser(impersonatedUser, session?.user.id as number);
|
||||
}
|
||||
|
||||
// Check session
|
||||
const sessionUserFromDb = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: session?.user.id,
|
||||
},
|
||||
include: {
|
||||
teams: {
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
role: {
|
||||
in: ["ADMIN", "OWNER"],
|
||||
},
|
||||
},
|
||||
{
|
||||
team: {
|
||||
id: teamId,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (sessionUserFromDb?.teams.length === 0 || impersonatedUser.teams.length === 0) {
|
||||
throw new Error("You do not have permission to do this.");
|
||||
}
|
||||
|
||||
return auditAndReturnNextUser(impersonatedUser, session?.user.id as number);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -63,6 +63,7 @@ export async function getTeamWithMembers(id?: number, slug?: string) {
|
|||
isMissingSeat: obj.user.plan === UserPlan.FREE,
|
||||
role: membership?.role,
|
||||
accepted: membership?.accepted,
|
||||
disableImpersonation: membership?.disableImpersonation,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
@ -11,10 +11,12 @@ import SAMLConfiguration from "@ee/components/saml/Configuration";
|
|||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||
import useCurrentUserId from "@lib/hooks/useCurrentUserId";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import Shell from "@components/Shell";
|
||||
import DisableTeamImpersonation from "@components/team/DisableTeamImpersonation";
|
||||
import MemberInvitationModal from "@components/team/MemberInvitationModal";
|
||||
import MemberList from "@components/team/MemberList";
|
||||
import TeamSettings from "@components/team/TeamSettings";
|
||||
|
@ -25,6 +27,7 @@ import Avatar from "@components/ui/Avatar";
|
|||
export function TeamSettingsPage() {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const userId = useCurrentUserId();
|
||||
|
||||
const upgraded = router.query.upgraded as string;
|
||||
|
||||
|
@ -165,6 +168,7 @@ export function TeamSettingsPage() {
|
|||
</div>
|
||||
<MemberList team={team} members={team.members || []} />
|
||||
{isAdmin && <SAMLConfiguration teamsView={true} teamId={team.id} />}
|
||||
{userId && <DisableTeamImpersonation teamId={team.id} memberId={userId} />}
|
||||
</div>
|
||||
<div className="min-w-32 mt-8 w-full px-2 ltr:ml-2 rtl:mr-2 sm:mt-0 md:w-3/12">
|
||||
<TeamSettingsRightSidebar role={team.membership.role} team={team} />
|
||||
|
|
|
@ -838,6 +838,7 @@
|
|||
"impersonate": "Impersonate",
|
||||
"user_impersonation_heading": "User Impersonation",
|
||||
"user_impersonation_description": "Allows our support team to temporarily sign in as you to help us quickly resolve any issues you report to us.",
|
||||
"team_impersonation_description": "Allows your team admins to temporarily sign in as you.",
|
||||
"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>.",
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { MembershipRole, Prisma, UserPlan } from "@prisma/client";
|
||||
import { randomBytes } from "crypto";
|
||||
import { resolve } from "path";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getUserAvailability } from "@calcom/core/getUserAvailability";
|
||||
|
@ -460,4 +461,54 @@ export const viewerTeamsRouter = createProtectedRouter()
|
|||
async resolve({ ctx, input }) {
|
||||
return await ensureSubscriptionQuantityCorrectness(ctx.user.id, input.teamId);
|
||||
},
|
||||
})
|
||||
.query("getMembershipbyUser", {
|
||||
input: z.object({
|
||||
teamId: z.number(),
|
||||
memberId: z.number(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
if (ctx.user.id !== input.memberId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You cannot view memberships that are not your own.",
|
||||
});
|
||||
}
|
||||
|
||||
return await ctx.prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId: input.memberId,
|
||||
teamId: input.teamId,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
})
|
||||
.mutation("updateMembership", {
|
||||
input: z.object({
|
||||
teamId: z.number(),
|
||||
memberId: z.number(),
|
||||
disableImpersonation: z.boolean(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
if (ctx.user.id !== input.memberId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You cannot edit memberships that are not your own.",
|
||||
});
|
||||
}
|
||||
|
||||
return await ctx.prisma.membership.update({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId: input.memberId,
|
||||
teamId: input.teamId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
disableImpersonation: input.disableImpersonation,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Membership" ADD COLUMN "disableImpersonation" BOOLEAN NOT NULL DEFAULT false;
|
|
@ -210,8 +210,9 @@ model Membership {
|
|||
userId Int
|
||||
accepted Boolean @default(false)
|
||||
role MembershipRole
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
team Team @relation(fields: [teamId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
disableImpersonation Boolean @default(false)
|
||||
|
||||
@@id([userId, teamId])
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { BookingStatus, MembershipRole, Prisma, UserPlan } from "@prisma/client";
|
||||
import { BookingStatus, MembershipRole, Prisma, UserPermissionRole, UserPlan } from "@prisma/client";
|
||||
import { uuid } from "short-uuid";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
|
@ -18,6 +18,7 @@ async function createUserAndEventType(opts: {
|
|||
name: string;
|
||||
completedOnboarding?: boolean;
|
||||
timeZone?: string;
|
||||
role?: UserPermissionRole;
|
||||
};
|
||||
eventTypes: Array<
|
||||
Prisma.EventTypeCreateInput & {
|
||||
|
@ -468,6 +469,18 @@ async function main() {
|
|||
eventTypes: [],
|
||||
});
|
||||
|
||||
await createUserAndEventType({
|
||||
user: {
|
||||
email: "admin@example.com",
|
||||
password: "admin",
|
||||
username: "admin",
|
||||
name: "Admin Example",
|
||||
plan: "PRO",
|
||||
role: "ADMIN",
|
||||
},
|
||||
eventTypes: [],
|
||||
});
|
||||
|
||||
const pro2UserTeam = await createUserAndEventType({
|
||||
user: {
|
||||
email: "teampro2@example.com",
|
||||
|
@ -536,6 +549,7 @@ async function main() {
|
|||
{
|
||||
id: pro2UserTeam.id,
|
||||
username: pro2UserTeam.name || "Unknown",
|
||||
role: "MEMBER",
|
||||
},
|
||||
{
|
||||
id: pro3UserTeam.id,
|
||||
|
|
Loading…
Reference in New Issue