feat: implement user impersonation feature (#976)

* feat: wip impersonate user

* feat: add ability to impersonate an user

* fix: remove console.log

* fix: unused import
This commit is contained in:
Jérémy M
2023-08-01 00:47:29 +02:00
committed by GitHub
parent b028d9fd2a
commit f111440e00
24 changed files with 547 additions and 30 deletions

View File

@ -39,8 +39,10 @@ export const VERIFY = gql`
displayName
firstName
lastName
canImpersonate
workspaceMember {
id
allowImpersonation
workspace {
id
domainName
@ -85,3 +87,45 @@ export const RENEW_TOKEN = gql`
}
}
`;
// TODO: Fragments should be used instead of duplicating the user fields !
export const IMPERSONATE = gql`
mutation Impersonate($userId: String!) {
impersonate(userId: $userId) {
user {
id
email
displayName
firstName
lastName
canImpersonate
workspaceMember {
id
allowImpersonation
workspace {
id
domainName
displayName
logo
inviteHash
}
}
settings {
id
colorScheme
locale
}
}
tokens {
accessToken {
token
expiresAt
}
refreshToken {
token
expiresAt
}
}
}
}
`;

View File

@ -0,0 +1,39 @@
import { useRecoilValue } from 'recoil';
import { currentUserState } from '@/auth/states/currentUserState';
import { Toggle } from '@/ui/input/toggle/components/Toggle';
import { useSnackBar } from '@/ui/snack-bar/hooks/useSnackBar';
import { useUpdateAllowImpersonationMutation } from '~/generated/graphql';
export function ToggleField() {
const { enqueueSnackBar } = useSnackBar();
const currentUser = useRecoilValue(currentUserState);
const [updateAllowImpersonation] = useUpdateAllowImpersonationMutation();
async function handleChange(value: boolean) {
try {
const { data, errors } = await updateAllowImpersonation({
variables: {
allowImpersonation: value,
},
});
if (errors || !data?.allowImpersonation) {
throw new Error('Error while updating user');
}
} catch (err: any) {
enqueueSnackBar(err?.message, {
variant: 'error',
});
}
}
return (
<Toggle
value={currentUser?.workspaceMember?.allowImpersonation}
onChange={handleChange}
/>
);
}

View File

@ -17,4 +17,7 @@ export enum AppPath {
PersonShowPage = '/person/:personId',
OpportunitiesPage = '/opportunities',
SettingsCatchAll = `/settings/*`,
// Impersonate
Impersonate = '/impersonate/:userId',
}

View File

@ -0,0 +1,63 @@
import React, { useEffect, useState } from 'react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
type ContainerProps = {
isOn: boolean;
color?: string;
};
const Container = styled.div<ContainerProps>`
align-items: center;
background-color: ${({ theme, isOn, color }) =>
isOn ? color ?? theme.color.blue : theme.background.quaternary};
border-radius: 10px;
cursor: pointer;
display: flex;
height: 20px;
transition: background-color 0.3s ease;
width: 32px;
`;
const Circle = styled(motion.div)`
background-color: #fff;
border-radius: 50%;
height: 16px;
width: 16px;
`;
const circleVariants = {
on: { x: 14 },
off: { x: 2 },
};
export type ToggleProps = {
value?: boolean;
onChange?: (value: boolean) => void;
color?: string;
};
export function Toggle({ value, onChange, color }: ToggleProps) {
const [isOn, setIsOn] = useState(value ?? false);
function handleChange() {
setIsOn(!isOn);
if (onChange) {
onChange(!isOn);
}
}
useEffect(() => {
if (value !== isOn) {
setIsOn(value ?? false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
return (
<Container onClick={handleChange} isOn={isOn} color={color}>
<Circle animate={isOn ? 'on' : 'off'} variants={circleVariants} />
</Container>
);
}

View File

@ -3,6 +3,7 @@ import styled from '@emotion/styled';
type Props = {
title: string;
description?: string;
addornment?: React.ReactNode;
};
const StyledContainer = styled.div`
@ -11,6 +12,12 @@ const StyledContainer = styled.div`
margin-bottom: ${({ theme }) => theme.spacing(4)};
`;
const StyledTitleContainer = styled.div`
align-items: center;
display: flex;
justify-content: space-between;
`;
const StyledTitle = styled.h2`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.md};
@ -26,10 +33,13 @@ const StyledDescription = styled.h3`
margin-top: ${({ theme }) => theme.spacing(3)};
`;
export function H2Title({ title, description }: Props) {
export function H2Title({ title, description, addornment }: Props) {
return (
<StyledContainer>
<StyledTitle>{title}</StyledTitle>
<StyledTitleContainer>
<StyledTitle>{title}</StyledTitle>
{addornment}
</StyledTitleContainer>
{description && <StyledDescription>{description}</StyledDescription>}
</StyledContainer>
);

View File

@ -10,8 +10,10 @@ export const GET_CURRENT_USER = gql`
firstName
lastName
avatarUrl
canImpersonate
workspaceMember {
id
allowImpersonation
workspace {
id
domainName

View File

@ -28,6 +28,15 @@ export const UPDATE_USER = gql`
}
`;
export const UPDATE_ALLOW_IMPERONATION = gql`
mutation UpdateAllowImpersonation($allowImpersonation: Boolean!) {
allowImpersonation(allowImpersonation: $allowImpersonation) {
id
allowImpersonation
}
}
`;
export const UPDATE_PROFILE_PICTURE = gql`
mutation UploadProfilePicture($file: Upload!) {
uploadProfilePicture(file: $file)