diff --git a/front/src/App.tsx b/front/src/App.tsx index 23539e9fe..877351f31 100644 --- a/front/src/App.tsx +++ b/front/src/App.tsx @@ -5,9 +5,11 @@ import { SettingsPath } from '@/types/SettingsPath'; import { DefaultLayout } from '@/ui/layout/components/DefaultLayout'; import { CreateProfile } from '~/pages/auth/CreateProfile'; import { CreateWorkspace } from '~/pages/auth/CreateWorkspace'; +import { SignInUp } from '~/pages/auth/SignInUp'; import { Verify } from '~/pages/auth/Verify'; import { Companies } from '~/pages/companies/Companies'; import { CompanyShow } from '~/pages/companies/CompanyShow'; +import { Impersonate } from '~/pages/impersonate/Impersonate'; import { Opportunities } from '~/pages/opportunities/Opportunities'; import { People } from '~/pages/people/People'; import { PersonShow } from '~/pages/people/PersonShow'; @@ -17,8 +19,6 @@ import { SettingsWorksapce } from '~/pages/settings/SettingsWorkspace'; import { SettingsWorkspaceMembers } from '~/pages/settings/SettingsWorkspaceMembers'; import { AppInternalHooks } from '~/sync-hooks/AppInternalHooks'; -import { SignInUp } from './pages/auth/SignInUp'; - // TEMP FEATURE FLAG FOR VIEW FIELDS export const ACTIVATE_VIEW_FIELDS = true; @@ -39,6 +39,7 @@ export function App() { } /> } /> } /> + } /> } /> >; authoredAttachments?: Maybe>; avatarUrl?: Maybe; + canImpersonate: Scalars['Boolean']; comments?: Maybe>; companies?: Maybe>; createdAt: Scalars['DateTime']; @@ -1885,6 +1898,7 @@ export type UserOrderByWithRelationInput = { authoredActivities?: InputMaybe; authoredAttachments?: InputMaybe; avatarUrl?: InputMaybe; + canImpersonate?: InputMaybe; comments?: InputMaybe; companies?: InputMaybe; createdAt?: InputMaybe; @@ -1910,6 +1924,7 @@ export type UserRelationFilter = { export enum UserScalarFieldEnum { AvatarUrl = 'avatarUrl', + CanImpersonate = 'canImpersonate', CreatedAt = 'createdAt', DeletedAt = 'deletedAt', Disabled = 'disabled', @@ -1980,6 +1995,7 @@ export type UserUpdateInput = { authoredActivities?: InputMaybe; authoredAttachments?: InputMaybe; avatarUrl?: InputMaybe; + canImpersonate?: InputMaybe; comments?: InputMaybe; companies?: InputMaybe; createdAt?: InputMaybe; @@ -2019,6 +2035,7 @@ export type UserWhereInput = { authoredActivities?: InputMaybe; authoredAttachments?: InputMaybe; avatarUrl?: InputMaybe; + canImpersonate?: InputMaybe; comments?: InputMaybe; companies?: InputMaybe; createdAt?: InputMaybe; @@ -2138,6 +2155,7 @@ export type WorkspaceInviteHashValid = { export type WorkspaceMember = { __typename?: 'WorkspaceMember'; + allowImpersonation: Scalars['Boolean']; createdAt: Scalars['DateTime']; id: Scalars['ID']; updatedAt: Scalars['DateTime']; @@ -2147,6 +2165,7 @@ export type WorkspaceMember = { }; export type WorkspaceMemberOrderByWithRelationInput = { + allowImpersonation?: InputMaybe; createdAt?: InputMaybe; id?: InputMaybe; updatedAt?: InputMaybe; @@ -2155,6 +2174,7 @@ export type WorkspaceMemberOrderByWithRelationInput = { }; export enum WorkspaceMemberScalarFieldEnum { + AllowImpersonation = 'allowImpersonation', CreatedAt = 'createdAt', DeletedAt = 'deletedAt', Id = 'id', @@ -2173,6 +2193,7 @@ export type WorkspaceMemberWhereInput = { AND?: InputMaybe>; NOT?: InputMaybe>; OR?: InputMaybe>; + allowImpersonation?: InputMaybe; createdAt?: InputMaybe; id?: InputMaybe; updatedAt?: InputMaybe; @@ -2321,7 +2342,7 @@ export type VerifyMutationVariables = Exact<{ }>; -export type VerifyMutation = { __typename?: 'Mutation', verify: { __typename?: 'Verify', user: { __typename?: 'User', id: string, email: string, displayName: string, firstName?: string | null, lastName?: string | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, workspace: { __typename?: 'Workspace', id: string, domainName?: string | null, displayName?: string | null, logo?: string | null, inviteHash?: string | null } } | null, settings: { __typename?: 'UserSettings', id: string, colorScheme: ColorScheme, locale: string } }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; +export type VerifyMutation = { __typename?: 'Mutation', verify: { __typename?: 'Verify', user: { __typename?: 'User', id: string, email: string, displayName: string, firstName?: string | null, lastName?: string | null, canImpersonate: boolean, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, allowImpersonation: boolean, workspace: { __typename?: 'Workspace', id: string, domainName?: string | null, displayName?: string | null, logo?: string | null, inviteHash?: string | null } } | null, settings: { __typename?: 'UserSettings', id: string, colorScheme: ColorScheme, locale: string } }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; export type RenewTokenMutationVariables = Exact<{ refreshToken: Scalars['String']; @@ -2330,6 +2351,13 @@ export type RenewTokenMutationVariables = Exact<{ export type RenewTokenMutation = { __typename?: 'Mutation', renewToken: { __typename?: 'AuthTokens', tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', expiresAt: string, token: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; +export type ImpersonateMutationVariables = Exact<{ + userId: Scalars['String']; +}>; + + +export type ImpersonateMutation = { __typename?: 'Mutation', impersonate: { __typename?: 'Verify', user: { __typename?: 'User', id: string, email: string, displayName: string, firstName?: string | null, lastName?: string | null, canImpersonate: boolean, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, allowImpersonation: boolean, workspace: { __typename?: 'Workspace', id: string, domainName?: string | null, displayName?: string | null, logo?: string | null, inviteHash?: string | null } } | null, settings: { __typename?: 'UserSettings', id: string, colorScheme: ColorScheme, locale: string } }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; + export type GetClientConfigQueryVariables = Exact<{ [key: string]: never; }>; @@ -2563,7 +2591,7 @@ export type SearchActivityQuery = { __typename?: 'Query', searchResults: Array<{ export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>; -export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, email: string, displayName: string, firstName?: string | null, lastName?: string | null, avatarUrl?: string | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, workspace: { __typename?: 'Workspace', id: string, domainName?: string | null, displayName?: string | null, logo?: string | null, inviteHash?: string | null } } | null, settings: { __typename?: 'UserSettings', id: string, locale: string, colorScheme: ColorScheme } } }; +export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, email: string, displayName: string, firstName?: string | null, lastName?: string | null, avatarUrl?: string | null, canImpersonate: boolean, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, allowImpersonation: boolean, workspace: { __typename?: 'Workspace', id: string, domainName?: string | null, displayName?: string | null, logo?: string | null, inviteHash?: string | null } } | null, settings: { __typename?: 'UserSettings', id: string, locale: string, colorScheme: ColorScheme } } }; export type GetUsersQueryVariables = Exact<{ [key: string]: never; }>; @@ -2578,6 +2606,13 @@ export type UpdateUserMutationVariables = Exact<{ export type UpdateUserMutation = { __typename?: 'Mutation', updateUser: { __typename?: 'User', id: string, email: string, displayName: string, firstName?: string | null, lastName?: string | null, avatarUrl?: string | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, workspace: { __typename?: 'Workspace', id: string, domainName?: string | null, displayName?: string | null, logo?: string | null, inviteHash?: string | null } } | null, settings: { __typename?: 'UserSettings', id: string, locale: string, colorScheme: ColorScheme } } }; +export type UpdateAllowImpersonationMutationVariables = Exact<{ + allowImpersonation: Scalars['Boolean']; +}>; + + +export type UpdateAllowImpersonationMutation = { __typename?: 'Mutation', allowImpersonation: { __typename?: 'WorkspaceMember', id: string, allowImpersonation: boolean } }; + export type UploadProfilePictureMutationVariables = Exact<{ file: Scalars['Upload']; }>; @@ -3265,8 +3300,10 @@ export const VerifyDocument = gql` displayName firstName lastName + canImpersonate workspaceMember { id + allowImpersonation workspace { id domainName @@ -3362,6 +3399,72 @@ export function useRenewTokenMutation(baseOptions?: Apollo.MutationHookOptions; export type RenewTokenMutationResult = Apollo.MutationResult; export type RenewTokenMutationOptions = Apollo.BaseMutationOptions; +export const ImpersonateDocument = 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 + } + } + } +} + `; +export type ImpersonateMutationFn = Apollo.MutationFunction; + +/** + * __useImpersonateMutation__ + * + * To run a mutation, you first call `useImpersonateMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useImpersonateMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [impersonateMutation, { data, loading, error }] = useImpersonateMutation({ + * variables: { + * userId: // value for 'userId' + * }, + * }); + */ +export function useImpersonateMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(ImpersonateDocument, options); + } +export type ImpersonateMutationHookResult = ReturnType; +export type ImpersonateMutationResult = Apollo.MutationResult; +export type ImpersonateMutationOptions = Apollo.BaseMutationOptions; export const GetClientConfigDocument = gql` query GetClientConfig { clientConfig { @@ -4606,8 +4709,10 @@ export const GetCurrentUserDocument = gql` firstName lastName avatarUrl + canImpersonate workspaceMember { id + allowImpersonation workspace { id domainName @@ -4743,6 +4848,40 @@ export function useUpdateUserMutation(baseOptions?: Apollo.MutationHookOptions; export type UpdateUserMutationResult = Apollo.MutationResult; export type UpdateUserMutationOptions = Apollo.BaseMutationOptions; +export const UpdateAllowImpersonationDocument = gql` + mutation UpdateAllowImpersonation($allowImpersonation: Boolean!) { + allowImpersonation(allowImpersonation: $allowImpersonation) { + id + allowImpersonation + } +} + `; +export type UpdateAllowImpersonationMutationFn = Apollo.MutationFunction; + +/** + * __useUpdateAllowImpersonationMutation__ + * + * To run a mutation, you first call `useUpdateAllowImpersonationMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useUpdateAllowImpersonationMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [updateAllowImpersonationMutation, { data, loading, error }] = useUpdateAllowImpersonationMutation({ + * variables: { + * allowImpersonation: // value for 'allowImpersonation' + * }, + * }); + */ +export function useUpdateAllowImpersonationMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(UpdateAllowImpersonationDocument, options); + } +export type UpdateAllowImpersonationMutationHookResult = ReturnType; +export type UpdateAllowImpersonationMutationResult = Apollo.MutationResult; +export type UpdateAllowImpersonationMutationOptions = Apollo.BaseMutationOptions; export const UploadProfilePictureDocument = gql` mutation UploadProfilePicture($file: Upload!) { uploadProfilePicture(file: $file) diff --git a/front/src/modules/auth/queries/update.ts b/front/src/modules/auth/queries/update.ts index a9ae96dee..674c0ff87 100644 --- a/front/src/modules/auth/queries/update.ts +++ b/front/src/modules/auth/queries/update.ts @@ -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 + } + } + } + } +`; diff --git a/front/src/modules/settings/profile/components/ToggleField.tsx b/front/src/modules/settings/profile/components/ToggleField.tsx new file mode 100644 index 000000000..393cbf17e --- /dev/null +++ b/front/src/modules/settings/profile/components/ToggleField.tsx @@ -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 ( + + ); +} diff --git a/front/src/modules/types/AppPath.ts b/front/src/modules/types/AppPath.ts index 2c784be25..27fa412ed 100644 --- a/front/src/modules/types/AppPath.ts +++ b/front/src/modules/types/AppPath.ts @@ -17,4 +17,7 @@ export enum AppPath { PersonShowPage = '/person/:personId', OpportunitiesPage = '/opportunities', SettingsCatchAll = `/settings/*`, + + // Impersonate + Impersonate = '/impersonate/:userId', } diff --git a/front/src/modules/ui/input/toggle/components/Toggle.tsx b/front/src/modules/ui/input/toggle/components/Toggle.tsx new file mode 100644 index 000000000..74888acee --- /dev/null +++ b/front/src/modules/ui/input/toggle/components/Toggle.tsx @@ -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` + 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 ( + + + + ); +} diff --git a/front/src/modules/ui/typography/components/H2Title.tsx b/front/src/modules/ui/typography/components/H2Title.tsx index 02791eae4..82ea64bdc 100644 --- a/front/src/modules/ui/typography/components/H2Title.tsx +++ b/front/src/modules/ui/typography/components/H2Title.tsx @@ -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 ( - {title} + + {title} + {addornment} + {description && {description}} ); diff --git a/front/src/modules/users/queries/index.ts b/front/src/modules/users/queries/index.ts index f4681e413..28b95facc 100644 --- a/front/src/modules/users/queries/index.ts +++ b/front/src/modules/users/queries/index.ts @@ -10,8 +10,10 @@ export const GET_CURRENT_USER = gql` firstName lastName avatarUrl + canImpersonate workspaceMember { id + allowImpersonation workspace { id domainName diff --git a/front/src/modules/users/queries/update.ts b/front/src/modules/users/queries/update.ts index 0bb595ad6..1069ea103 100644 --- a/front/src/modules/users/queries/update.ts +++ b/front/src/modules/users/queries/update.ts @@ -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) diff --git a/front/src/pages/impersonate/Impersonate.tsx b/front/src/pages/impersonate/Impersonate.tsx new file mode 100644 index 000000000..6c4a9890f --- /dev/null +++ b/front/src/pages/impersonate/Impersonate.tsx @@ -0,0 +1,57 @@ +import { useCallback, useEffect } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { useRecoilState, useSetRecoilState } from 'recoil'; + +import { useIsLogged } from '@/auth/hooks/useIsLogged'; +import { currentUserState } from '@/auth/states/currentUserState'; +import { tokenPairState } from '@/auth/states/tokenPairState'; +import { useImpersonateMutation } from '~/generated/graphql'; + +import { AppPath } from '../../modules/types/AppPath'; +import { isNonEmptyString } from '../../utils/isNonEmptyString'; + +export function Impersonate() { + const navigate = useNavigate(); + const { userId } = useParams(); + + const [currentUser, setCurrentUser] = useRecoilState(currentUserState); + const setTokenPair = useSetRecoilState(tokenPairState); + + const [impersonate] = useImpersonateMutation(); + + const isLogged = useIsLogged(); + + const handleImpersonate = useCallback(async () => { + if (!isNonEmptyString(userId)) { + return; + } + + const impersonateResult = await impersonate({ + variables: { userId }, + }); + + if (impersonateResult.errors) { + throw impersonateResult.errors; + } + + if (!impersonateResult.data?.impersonate) { + throw new Error('No impersonate result'); + } + + setCurrentUser(impersonateResult.data?.impersonate.user); + setTokenPair(impersonateResult.data?.impersonate.tokens); + + return impersonateResult.data?.impersonate; + }, [userId, impersonate, setCurrentUser, setTokenPair]); + + useEffect(() => { + if (isLogged && currentUser?.canImpersonate && isNonEmptyString(userId)) { + handleImpersonate(); + } else { + // User is not allowed to impersonate or not logged in + navigate(AppPath.Index); + } + }, [userId, currentUser, isLogged, handleImpersonate, navigate]); + + return <>; +} diff --git a/front/src/pages/settings/SettingsProfile.tsx b/front/src/pages/settings/SettingsProfile.tsx index 9a1581bb9..16deb72ca 100644 --- a/front/src/pages/settings/SettingsProfile.tsx +++ b/front/src/pages/settings/SettingsProfile.tsx @@ -4,6 +4,7 @@ import { DeleteAccount } from '@/settings/profile/components/DeleteAccount'; import { EmailField } from '@/settings/profile/components/EmailField'; import { NameFields } from '@/settings/profile/components/NameFields'; import { ProfilePictureUploader } from '@/settings/profile/components/ProfilePictureUploader'; +import { ToggleField } from '@/settings/profile/components/ToggleField'; import { IconSettings } from '@/ui/icon'; import { SubMenuTopBarContainer } from '@/ui/layout/components/SubMenuTopBarContainer'; import { Section } from '@/ui/section/components/Section'; @@ -44,6 +45,13 @@ export function SettingsProfile() { /> +
+ } + description="Grant Twenty support temporary access to your account so we can troubleshoot problems or recover content on your behalf. You can revoke access at any time." + /> +
diff --git a/front/src/testing/mock-data/users.ts b/front/src/testing/mock-data/users.ts index 3bdc3815e..3e71dae0c 100644 --- a/front/src/testing/mock-data/users.ts +++ b/front/src/testing/mock-data/users.ts @@ -16,9 +16,11 @@ export const mockedUsersData: Array = [ firstName: 'Charles', lastName: 'Test', avatarUrl: null, + canImpersonate: false, workspaceMember: { __typename: 'WorkspaceMember', id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b', + allowImpersonation: true, workspace: { __typename: 'Workspace', id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b', @@ -42,9 +44,11 @@ export const mockedUsersData: Array = [ displayName: 'Felix Test', firstName: 'Felix', lastName: 'Test', + canImpersonate: false, workspaceMember: { __typename: 'WorkspaceMember', id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b', + allowImpersonation: true, workspace: { __typename: 'Workspace', id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b', @@ -72,9 +76,11 @@ export const mockedOnboardingUsersData: Array = [ firstName: '', lastName: '', avatarUrl: null, + canImpersonate: false, workspaceMember: { __typename: 'WorkspaceMember', id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b', + allowImpersonation: true, workspace: { __typename: 'Workspace', id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b', @@ -99,9 +105,11 @@ export const mockedOnboardingUsersData: Array = [ firstName: '', lastName: '', avatarUrl: null, + canImpersonate: false, workspaceMember: { __typename: 'WorkspaceMember', id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b', + allowImpersonation: true, workspace: { __typename: 'Workspace', id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b', diff --git a/server/.eslintrc.js b/server/.eslintrc.js index 8888d79c7..6e43b5862 100644 --- a/server/.eslintrc.js +++ b/server/.eslintrc.js @@ -5,7 +5,7 @@ module.exports = { tsconfigRootDir : __dirname, sourceType: 'module', }, - plugins: ['@typescript-eslint/eslint-plugin', 'import'], + plugins: ['@typescript-eslint/eslint-plugin', 'import', 'unused-imports'], extends: [ 'plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended', @@ -74,5 +74,6 @@ module.exports = { pathGroupsExcludedImportTypes: ['@nestjs/**'], }, ], + 'unused-imports/no-unused-imports': 'warn', }, }; diff --git a/server/@types/common.d.ts b/server/@types/common.d.ts new file mode 100644 index 000000000..f203e1ce9 --- /dev/null +++ b/server/@types/common.d.ts @@ -0,0 +1,5 @@ +type DeepPartial = T extends object + ? { + [P in keyof T]?: DeepPartial; + } + : T; diff --git a/server/package.json b/server/package.json index f78d290b0..00e9341b4 100644 --- a/server/package.json +++ b/server/package.json @@ -103,6 +103,7 @@ "eslint-config-prettier": "^8.3.0", "eslint-plugin-import": "^2.27.5", "eslint-plugin-prettier": "^4.0.0", + "eslint-plugin-unused-imports": "^3.0.0", "jest": "28.1.3", "prettier": "^2.3.2", "prisma": "4.13.0", diff --git a/server/src/core/auth/auth.resolver.ts b/server/src/core/auth/auth.resolver.ts index 60fb9d24b..d90fde61e 100644 --- a/server/src/core/auth/auth.resolver.ts +++ b/server/src/core/auth/auth.resolver.ts @@ -1,5 +1,9 @@ import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; -import { BadRequestException } from '@nestjs/common'; +import { + BadRequestException, + ForbiddenException, + UseGuards, +} from '@nestjs/common'; import { Prisma } from '@prisma/client'; @@ -7,6 +11,10 @@ import { PrismaSelect, PrismaSelector, } from 'src/decorators/prisma-select.decorator'; +import { JwtAuthGuard } from 'src/guards/jwt.auth.guard'; +import { AuthUser } from 'src/decorators/auth-user.decorator'; +import { assert } from 'src/utils/assert'; +import { User } from 'src/core/@generated/user/user.model'; import { AuthTokens } from './dto/token.entity'; import { TokenService } from './services/token.service'; @@ -21,6 +29,7 @@ import { CheckUserExistsInput } from './dto/user-exists.input'; import { WorkspaceInviteHashValid } from './dto/workspace-invite-hash-valid.entity'; import { WorkspaceInviteHashValidInput } from './dto/workspace-invite-hash.input'; import { SignUpInput } from './dto/sign-up.input'; +import { ImpersonateInput } from './dto/impersonate.input'; @Resolver() export class AuthResolver { @@ -96,4 +105,30 @@ export class AuthResolver { return { tokens: tokens }; } + + @UseGuards(JwtAuthGuard) + @Mutation(() => Verify) + async impersonate( + @Args() impersonateInput: ImpersonateInput, + @AuthUser() user: User, + @PrismaSelector({ + modelName: 'User', + defaultFields: { + User: { + id: true, + workspaceMember: { select: { allowImpersonation: true } }, + }, + }, + }) + prismaSelect: PrismaSelect<'User'>, + ): Promise { + // Check if user can impersonate + assert(user.canImpersonate, 'User cannot impersonate', ForbiddenException); + const select = prismaSelect.valueOf('user') as Prisma.UserSelect & { + id: true; + workspaceMember: { select: { allowImpersonation: true } }; + }; + + return this.authService.impersonate(impersonateInput.userId, select); + } } diff --git a/server/src/core/auth/dto/impersonate.input.ts b/server/src/core/auth/dto/impersonate.input.ts new file mode 100644 index 000000000..331bb420a --- /dev/null +++ b/server/src/core/auth/dto/impersonate.input.ts @@ -0,0 +1,11 @@ +import { ArgsType, Field } from '@nestjs/graphql'; + +import { IsNotEmpty, IsString } from 'class-validator'; + +@ArgsType() +export class ImpersonateInput { + @Field(() => String) + @IsNotEmpty() + @IsString() + userId: string; +} diff --git a/server/src/core/auth/dto/verify.entity.ts b/server/src/core/auth/dto/verify.entity.ts index 3b327a8c7..ddf5e25b1 100644 --- a/server/src/core/auth/dto/verify.entity.ts +++ b/server/src/core/auth/dto/verify.entity.ts @@ -7,5 +7,5 @@ import { AuthTokens } from './token.entity'; @ObjectType() export class Verify extends AuthTokens { @Field(() => User) - user: Partial; + user: DeepPartial; } diff --git a/server/src/core/auth/services/auth.service.ts b/server/src/core/auth/services/auth.service.ts index 930d605c6..f725c4431 100644 --- a/server/src/core/auth/services/auth.service.ts +++ b/server/src/core/auth/services/auth.service.ts @@ -165,4 +165,41 @@ export class AuthService { return { isValid: !!workspace }; } + + async impersonate( + userId: string, + select: Prisma.UserSelect & { + id: true; + workspaceMember: { + select: { + allowImpersonation: true; + }; + }; + }, + ) { + const user = await this.userService.findUnique({ + where: { + id: userId, + }, + select, + }); + + assert(user, "This user doesn't exist", NotFoundException); + assert( + user.workspaceMember?.allowImpersonation, + 'Impersonation not allowed', + ForbiddenException, + ); + + const accessToken = await this.tokenService.generateAccessToken(user.id); + const refreshToken = await this.tokenService.generateRefreshToken(user.id); + + return { + user, + tokens: { + accessToken, + refreshToken, + }, + }; + } } diff --git a/server/src/core/workspace/resolvers/workspace-member.resolver.ts b/server/src/core/workspace/resolvers/workspace-member.resolver.ts index 42bcb76cc..e7045bcf6 100644 --- a/server/src/core/workspace/resolvers/workspace-member.resolver.ts +++ b/server/src/core/workspace/resolvers/workspace-member.resolver.ts @@ -20,6 +20,8 @@ import { import { WorkspaceMemberService } from 'src/core/workspace/services/workspace-member.service'; import { DeleteOneWorkspaceMemberArgs } from 'src/core/@generated/workspace-member/delete-one-workspace-member.args'; import { JwtAuthGuard } from 'src/guards/jwt.auth.guard'; +import { AuthUser } from 'src/decorators/auth-user.decorator'; +import { User } from 'src/core/@generated/user/user.model'; @UseGuards(JwtAuthGuard) @Resolver(() => WorkspaceMember) @@ -48,6 +50,24 @@ export class WorkspaceMemberResolver { }); } + @Mutation(() => WorkspaceMember) + async allowImpersonation( + @Args('allowImpersonation') allowImpersonation: boolean, + @AuthUser() user: User, + @PrismaSelector({ modelName: 'WorkspaceMember' }) + prismaSelect: PrismaSelect<'WorkspaceMember'>, + ): Promise> { + return this.workspaceMemberService.update({ + where: { + userId: user.id, + }, + data: { + allowImpersonation, + }, + select: prismaSelect.value, + }); + } + @Mutation(() => WorkspaceMember) @UseGuards(AbilityGuard) @CheckAbilities(DeleteWorkspaceMemberAbilityHandler) diff --git a/server/src/database/migrations/20230731072336_add_impersonate_ability/migration.sql b/server/src/database/migrations/20230731072336_add_impersonate_ability/migration.sql new file mode 100644 index 000000000..2a93c6ce6 --- /dev/null +++ b/server/src/database/migrations/20230731072336_add_impersonate_ability/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "canImpersonate" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "workspace_members" ADD COLUMN "allowImpersonation" BOOLEAN NOT NULL DEFAULT true; diff --git a/server/src/database/schema.prisma b/server/src/database/schema.prisma index a5573923b..ba650f8e3 100644 --- a/server/src/database/schema.prisma +++ b/server/src/database/schema.prisma @@ -65,39 +65,42 @@ generator nestgraphql { model User { /// @Validator.IsString() /// @Validator.IsOptional() - id String @id @default(uuid()) + id String @id @default(uuid()) /// @Validator.IsString() /// @Validator.IsOptional() - firstName String? + firstName String? /// @Validator.IsString() /// @Validator.IsOptional() - lastName String? + lastName String? /// @Validator.IsEmail() /// @Validator.IsOptional() - email String @unique + email String @unique /// @Validator.IsBoolean() /// @Validator.IsOptional() - emailVerified Boolean @default(false) + emailVerified Boolean @default(false) /// @Validator.IsString() /// @Validator.IsOptional() - avatarUrl String? + avatarUrl String? /// @Validator.IsString() /// @Validator.IsOptional() - locale String + locale String /// @Validator.IsString() /// @Validator.IsOptional() - phoneNumber String? + phoneNumber String? /// @Validator.IsDate() /// @Validator.IsOptional() - lastSeen DateTime? + lastSeen DateTime? /// @Validator.IsBoolean() /// @Validator.IsOptional() - disabled Boolean @default(false) + disabled Boolean @default(false) /// @TypeGraphQL.omit(input: true, output: true) - passwordHash String? + passwordHash String? /// @Validator.IsJSON() /// @Validator.IsOptional() - metadata Json? + metadata Json? + /// @Validator.IsBoolean() + /// @Validator.IsOptional() + canImpersonate Boolean @default(false) /// @TypeGraphQL.omit(input: true) workspaceMember WorkspaceMember? @@ -106,17 +109,17 @@ model User { refreshTokens RefreshToken[] comments Comment[] - authoredActivities Activity[] @relation(name: "authoredActivities") - assignedActivities Activity[] @relation(name: "assignedActivities") - settings UserSettings @relation(fields: [settingsId], references: [id]) - settingsId String @unique + authoredActivities Activity[] @relation(name: "authoredActivities") + assignedActivities Activity[] @relation(name: "assignedActivities") + authoredAttachments Attachment[] @relation(name: "authoredAttachments") + settings UserSettings @relation(fields: [settingsId], references: [id]) + settingsId String @unique /// @TypeGraphQL.omit(input: true, output: true) deletedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - authoredAttachments Attachment[] @relation(name: "authoredAttachments") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@map("users") } @@ -185,7 +188,10 @@ model Workspace { model WorkspaceMember { /// @Validator.IsString() /// @Validator.IsOptional() - id String @id @default(uuid()) + id String @id @default(uuid()) + /// @Validator.IsBoolean() + /// @Validator.IsOptional() + allowImpersonation Boolean @default(true) user User @relation(fields: [userId], references: [id]) userId String @unique diff --git a/server/tsconfig.json b/server/tsconfig.json index e5ca42a9a..419c096c9 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -21,6 +21,7 @@ "strictBindCallApply": false, "forceConsistentCasingInFileNames": false, "noFallthroughCasesInSwitch": false, - "resolveJsonModule": true + "resolveJsonModule": true, + "typeRoots": ["@types", "node_modules/@types"] } } diff --git a/server/yarn.lock b/server/yarn.lock index 82136fca7..75199a881 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -4776,6 +4776,18 @@ eslint-plugin-prettier@^4.0.0: dependencies: prettier-linter-helpers "^1.0.0" +eslint-plugin-unused-imports@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-3.0.0.tgz#d25175b0072ff16a91892c3aa72a09ca3a9e69e7" + integrity sha512-sduiswLJfZHeeBJ+MQaG+xYzSWdRXoSw61DpU13mzWumCkR0ufD0HmO4kdNokjrkluMHpj/7PJeN35pgbhW3kw== + dependencies: + eslint-rule-composer "^0.3.0" + +eslint-rule-composer@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/eslint-rule-composer/-/eslint-rule-composer-0.3.0.tgz#79320c927b0c5c0d3d3d2b76c8b4a488f25bbaf9" + integrity sha512-bt+Sh8CtDmn2OajxvNO+BX7Wn4CIWMpTRm3MaiKPCQcnnlm0CS2mhui6QaoeQugs+3Kj2ESKEEGJUdVafwhiCg== + eslint-scope@5.1.1, eslint-scope@^5.1.1: version "5.1.1" resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz"