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

@ -5,9 +5,11 @@ import { SettingsPath } from '@/types/SettingsPath';
import { DefaultLayout } from '@/ui/layout/components/DefaultLayout'; import { DefaultLayout } from '@/ui/layout/components/DefaultLayout';
import { CreateProfile } from '~/pages/auth/CreateProfile'; import { CreateProfile } from '~/pages/auth/CreateProfile';
import { CreateWorkspace } from '~/pages/auth/CreateWorkspace'; import { CreateWorkspace } from '~/pages/auth/CreateWorkspace';
import { SignInUp } from '~/pages/auth/SignInUp';
import { Verify } from '~/pages/auth/Verify'; import { Verify } from '~/pages/auth/Verify';
import { Companies } from '~/pages/companies/Companies'; import { Companies } from '~/pages/companies/Companies';
import { CompanyShow } from '~/pages/companies/CompanyShow'; import { CompanyShow } from '~/pages/companies/CompanyShow';
import { Impersonate } from '~/pages/impersonate/Impersonate';
import { Opportunities } from '~/pages/opportunities/Opportunities'; import { Opportunities } from '~/pages/opportunities/Opportunities';
import { People } from '~/pages/people/People'; import { People } from '~/pages/people/People';
import { PersonShow } from '~/pages/people/PersonShow'; import { PersonShow } from '~/pages/people/PersonShow';
@ -17,8 +19,6 @@ import { SettingsWorksapce } from '~/pages/settings/SettingsWorkspace';
import { SettingsWorkspaceMembers } from '~/pages/settings/SettingsWorkspaceMembers'; import { SettingsWorkspaceMembers } from '~/pages/settings/SettingsWorkspaceMembers';
import { AppInternalHooks } from '~/sync-hooks/AppInternalHooks'; import { AppInternalHooks } from '~/sync-hooks/AppInternalHooks';
import { SignInUp } from './pages/auth/SignInUp';
// TEMP FEATURE FLAG FOR VIEW FIELDS // TEMP FEATURE FLAG FOR VIEW FIELDS
export const ACTIVATE_VIEW_FIELDS = true; export const ACTIVATE_VIEW_FIELDS = true;
@ -39,6 +39,7 @@ export function App() {
<Route path={AppPath.PersonShowPage} element={<PersonShow />} /> <Route path={AppPath.PersonShowPage} element={<PersonShow />} />
<Route path={AppPath.CompaniesPage} element={<Companies />} /> <Route path={AppPath.CompaniesPage} element={<Companies />} />
<Route path={AppPath.CompanyShowPage} element={<CompanyShow />} /> <Route path={AppPath.CompanyShowPage} element={<CompanyShow />} />
<Route path={AppPath.Impersonate} element={<Impersonate />} />
<Route path={AppPath.OpportunitiesPage} element={<Opportunities />} /> <Route path={AppPath.OpportunitiesPage} element={<Opportunities />} />
<Route <Route

View File

@ -882,6 +882,7 @@ export type LoginToken = {
export type Mutation = { export type Mutation = {
__typename?: 'Mutation'; __typename?: 'Mutation';
allowImpersonation: WorkspaceMember;
challenge: LoginToken; challenge: LoginToken;
createEvent: Analytics; createEvent: Analytics;
createOneActivity: Activity; createOneActivity: Activity;
@ -896,6 +897,7 @@ export type Mutation = {
deleteManyPipelineProgress: AffectedRows; deleteManyPipelineProgress: AffectedRows;
deleteUserAccount: User; deleteUserAccount: User;
deleteWorkspaceMember: WorkspaceMember; deleteWorkspaceMember: WorkspaceMember;
impersonate: Verify;
renewToken: AuthTokens; renewToken: AuthTokens;
signUp: LoginToken; signUp: LoginToken;
updateOneActivity: Activity; updateOneActivity: Activity;
@ -915,6 +917,11 @@ export type Mutation = {
}; };
export type MutationAllowImpersonationArgs = {
allowImpersonation: Scalars['Boolean'];
};
export type MutationChallengeArgs = { export type MutationChallengeArgs = {
email: Scalars['String']; email: Scalars['String'];
password: Scalars['String']; password: Scalars['String'];
@ -977,6 +984,11 @@ export type MutationDeleteWorkspaceMemberArgs = {
}; };
export type MutationImpersonateArgs = {
userId: Scalars['String'];
};
export type MutationRenewTokenArgs = { export type MutationRenewTokenArgs = {
refreshToken: Scalars['String']; refreshToken: Scalars['String'];
}; };
@ -1839,6 +1851,7 @@ export type User = {
authoredActivities?: Maybe<Array<Activity>>; authoredActivities?: Maybe<Array<Activity>>;
authoredAttachments?: Maybe<Array<Attachment>>; authoredAttachments?: Maybe<Array<Attachment>>;
avatarUrl?: Maybe<Scalars['String']>; avatarUrl?: Maybe<Scalars['String']>;
canImpersonate: Scalars['Boolean'];
comments?: Maybe<Array<Comment>>; comments?: Maybe<Array<Comment>>;
companies?: Maybe<Array<Company>>; companies?: Maybe<Array<Company>>;
createdAt: Scalars['DateTime']; createdAt: Scalars['DateTime'];
@ -1885,6 +1898,7 @@ export type UserOrderByWithRelationInput = {
authoredActivities?: InputMaybe<ActivityOrderByRelationAggregateInput>; authoredActivities?: InputMaybe<ActivityOrderByRelationAggregateInput>;
authoredAttachments?: InputMaybe<AttachmentOrderByRelationAggregateInput>; authoredAttachments?: InputMaybe<AttachmentOrderByRelationAggregateInput>;
avatarUrl?: InputMaybe<SortOrder>; avatarUrl?: InputMaybe<SortOrder>;
canImpersonate?: InputMaybe<SortOrder>;
comments?: InputMaybe<CommentOrderByRelationAggregateInput>; comments?: InputMaybe<CommentOrderByRelationAggregateInput>;
companies?: InputMaybe<CompanyOrderByRelationAggregateInput>; companies?: InputMaybe<CompanyOrderByRelationAggregateInput>;
createdAt?: InputMaybe<SortOrder>; createdAt?: InputMaybe<SortOrder>;
@ -1910,6 +1924,7 @@ export type UserRelationFilter = {
export enum UserScalarFieldEnum { export enum UserScalarFieldEnum {
AvatarUrl = 'avatarUrl', AvatarUrl = 'avatarUrl',
CanImpersonate = 'canImpersonate',
CreatedAt = 'createdAt', CreatedAt = 'createdAt',
DeletedAt = 'deletedAt', DeletedAt = 'deletedAt',
Disabled = 'disabled', Disabled = 'disabled',
@ -1980,6 +1995,7 @@ export type UserUpdateInput = {
authoredActivities?: InputMaybe<ActivityUpdateManyWithoutAuthorNestedInput>; authoredActivities?: InputMaybe<ActivityUpdateManyWithoutAuthorNestedInput>;
authoredAttachments?: InputMaybe<AttachmentUpdateManyWithoutAuthorNestedInput>; authoredAttachments?: InputMaybe<AttachmentUpdateManyWithoutAuthorNestedInput>;
avatarUrl?: InputMaybe<Scalars['String']>; avatarUrl?: InputMaybe<Scalars['String']>;
canImpersonate?: InputMaybe<Scalars['Boolean']>;
comments?: InputMaybe<CommentUpdateManyWithoutAuthorNestedInput>; comments?: InputMaybe<CommentUpdateManyWithoutAuthorNestedInput>;
companies?: InputMaybe<CompanyUpdateManyWithoutAccountOwnerNestedInput>; companies?: InputMaybe<CompanyUpdateManyWithoutAccountOwnerNestedInput>;
createdAt?: InputMaybe<Scalars['DateTime']>; createdAt?: InputMaybe<Scalars['DateTime']>;
@ -2019,6 +2035,7 @@ export type UserWhereInput = {
authoredActivities?: InputMaybe<ActivityListRelationFilter>; authoredActivities?: InputMaybe<ActivityListRelationFilter>;
authoredAttachments?: InputMaybe<AttachmentListRelationFilter>; authoredAttachments?: InputMaybe<AttachmentListRelationFilter>;
avatarUrl?: InputMaybe<StringNullableFilter>; avatarUrl?: InputMaybe<StringNullableFilter>;
canImpersonate?: InputMaybe<BoolFilter>;
comments?: InputMaybe<CommentListRelationFilter>; comments?: InputMaybe<CommentListRelationFilter>;
companies?: InputMaybe<CompanyListRelationFilter>; companies?: InputMaybe<CompanyListRelationFilter>;
createdAt?: InputMaybe<DateTimeFilter>; createdAt?: InputMaybe<DateTimeFilter>;
@ -2138,6 +2155,7 @@ export type WorkspaceInviteHashValid = {
export type WorkspaceMember = { export type WorkspaceMember = {
__typename?: 'WorkspaceMember'; __typename?: 'WorkspaceMember';
allowImpersonation: Scalars['Boolean'];
createdAt: Scalars['DateTime']; createdAt: Scalars['DateTime'];
id: Scalars['ID']; id: Scalars['ID'];
updatedAt: Scalars['DateTime']; updatedAt: Scalars['DateTime'];
@ -2147,6 +2165,7 @@ export type WorkspaceMember = {
}; };
export type WorkspaceMemberOrderByWithRelationInput = { export type WorkspaceMemberOrderByWithRelationInput = {
allowImpersonation?: InputMaybe<SortOrder>;
createdAt?: InputMaybe<SortOrder>; createdAt?: InputMaybe<SortOrder>;
id?: InputMaybe<SortOrder>; id?: InputMaybe<SortOrder>;
updatedAt?: InputMaybe<SortOrder>; updatedAt?: InputMaybe<SortOrder>;
@ -2155,6 +2174,7 @@ export type WorkspaceMemberOrderByWithRelationInput = {
}; };
export enum WorkspaceMemberScalarFieldEnum { export enum WorkspaceMemberScalarFieldEnum {
AllowImpersonation = 'allowImpersonation',
CreatedAt = 'createdAt', CreatedAt = 'createdAt',
DeletedAt = 'deletedAt', DeletedAt = 'deletedAt',
Id = 'id', Id = 'id',
@ -2173,6 +2193,7 @@ export type WorkspaceMemberWhereInput = {
AND?: InputMaybe<Array<WorkspaceMemberWhereInput>>; AND?: InputMaybe<Array<WorkspaceMemberWhereInput>>;
NOT?: InputMaybe<Array<WorkspaceMemberWhereInput>>; NOT?: InputMaybe<Array<WorkspaceMemberWhereInput>>;
OR?: InputMaybe<Array<WorkspaceMemberWhereInput>>; OR?: InputMaybe<Array<WorkspaceMemberWhereInput>>;
allowImpersonation?: InputMaybe<BoolFilter>;
createdAt?: InputMaybe<DateTimeFilter>; createdAt?: InputMaybe<DateTimeFilter>;
id?: InputMaybe<StringFilter>; id?: InputMaybe<StringFilter>;
updatedAt?: InputMaybe<DateTimeFilter>; updatedAt?: InputMaybe<DateTimeFilter>;
@ -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<{ export type RenewTokenMutationVariables = Exact<{
refreshToken: Scalars['String']; 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 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; }>; 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 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; }>; 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 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<{ export type UploadProfilePictureMutationVariables = Exact<{
file: Scalars['Upload']; file: Scalars['Upload'];
}>; }>;
@ -3265,8 +3300,10 @@ export const VerifyDocument = gql`
displayName displayName
firstName firstName
lastName lastName
canImpersonate
workspaceMember { workspaceMember {
id id
allowImpersonation
workspace { workspace {
id id
domainName domainName
@ -3362,6 +3399,72 @@ export function useRenewTokenMutation(baseOptions?: Apollo.MutationHookOptions<R
export type RenewTokenMutationHookResult = ReturnType<typeof useRenewTokenMutation>; export type RenewTokenMutationHookResult = ReturnType<typeof useRenewTokenMutation>;
export type RenewTokenMutationResult = Apollo.MutationResult<RenewTokenMutation>; export type RenewTokenMutationResult = Apollo.MutationResult<RenewTokenMutation>;
export type RenewTokenMutationOptions = Apollo.BaseMutationOptions<RenewTokenMutation, RenewTokenMutationVariables>; export type RenewTokenMutationOptions = Apollo.BaseMutationOptions<RenewTokenMutation, RenewTokenMutationVariables>;
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<ImpersonateMutation, ImpersonateMutationVariables>;
/**
* __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<ImpersonateMutation, ImpersonateMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<ImpersonateMutation, ImpersonateMutationVariables>(ImpersonateDocument, options);
}
export type ImpersonateMutationHookResult = ReturnType<typeof useImpersonateMutation>;
export type ImpersonateMutationResult = Apollo.MutationResult<ImpersonateMutation>;
export type ImpersonateMutationOptions = Apollo.BaseMutationOptions<ImpersonateMutation, ImpersonateMutationVariables>;
export const GetClientConfigDocument = gql` export const GetClientConfigDocument = gql`
query GetClientConfig { query GetClientConfig {
clientConfig { clientConfig {
@ -4606,8 +4709,10 @@ export const GetCurrentUserDocument = gql`
firstName firstName
lastName lastName
avatarUrl avatarUrl
canImpersonate
workspaceMember { workspaceMember {
id id
allowImpersonation
workspace { workspace {
id id
domainName domainName
@ -4743,6 +4848,40 @@ export function useUpdateUserMutation(baseOptions?: Apollo.MutationHookOptions<U
export type UpdateUserMutationHookResult = ReturnType<typeof useUpdateUserMutation>; export type UpdateUserMutationHookResult = ReturnType<typeof useUpdateUserMutation>;
export type UpdateUserMutationResult = Apollo.MutationResult<UpdateUserMutation>; export type UpdateUserMutationResult = Apollo.MutationResult<UpdateUserMutation>;
export type UpdateUserMutationOptions = Apollo.BaseMutationOptions<UpdateUserMutation, UpdateUserMutationVariables>; export type UpdateUserMutationOptions = Apollo.BaseMutationOptions<UpdateUserMutation, UpdateUserMutationVariables>;
export const UpdateAllowImpersonationDocument = gql`
mutation UpdateAllowImpersonation($allowImpersonation: Boolean!) {
allowImpersonation(allowImpersonation: $allowImpersonation) {
id
allowImpersonation
}
}
`;
export type UpdateAllowImpersonationMutationFn = Apollo.MutationFunction<UpdateAllowImpersonationMutation, UpdateAllowImpersonationMutationVariables>;
/**
* __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<UpdateAllowImpersonationMutation, UpdateAllowImpersonationMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<UpdateAllowImpersonationMutation, UpdateAllowImpersonationMutationVariables>(UpdateAllowImpersonationDocument, options);
}
export type UpdateAllowImpersonationMutationHookResult = ReturnType<typeof useUpdateAllowImpersonationMutation>;
export type UpdateAllowImpersonationMutationResult = Apollo.MutationResult<UpdateAllowImpersonationMutation>;
export type UpdateAllowImpersonationMutationOptions = Apollo.BaseMutationOptions<UpdateAllowImpersonationMutation, UpdateAllowImpersonationMutationVariables>;
export const UploadProfilePictureDocument = gql` export const UploadProfilePictureDocument = gql`
mutation UploadProfilePicture($file: Upload!) { mutation UploadProfilePicture($file: Upload!) {
uploadProfilePicture(file: $file) uploadProfilePicture(file: $file)

View File

@ -39,8 +39,10 @@ export const VERIFY = gql`
displayName displayName
firstName firstName
lastName lastName
canImpersonate
workspaceMember { workspaceMember {
id id
allowImpersonation
workspace { workspace {
id id
domainName 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', PersonShowPage = '/person/:personId',
OpportunitiesPage = '/opportunities', OpportunitiesPage = '/opportunities',
SettingsCatchAll = `/settings/*`, 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 = { type Props = {
title: string; title: string;
description?: string; description?: string;
addornment?: React.ReactNode;
}; };
const StyledContainer = styled.div` const StyledContainer = styled.div`
@ -11,6 +12,12 @@ const StyledContainer = styled.div`
margin-bottom: ${({ theme }) => theme.spacing(4)}; margin-bottom: ${({ theme }) => theme.spacing(4)};
`; `;
const StyledTitleContainer = styled.div`
align-items: center;
display: flex;
justify-content: space-between;
`;
const StyledTitle = styled.h2` const StyledTitle = styled.h2`
color: ${({ theme }) => theme.font.color.primary}; color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.md}; font-size: ${({ theme }) => theme.font.size.md};
@ -26,10 +33,13 @@ const StyledDescription = styled.h3`
margin-top: ${({ theme }) => theme.spacing(3)}; margin-top: ${({ theme }) => theme.spacing(3)};
`; `;
export function H2Title({ title, description }: Props) { export function H2Title({ title, description, addornment }: Props) {
return ( return (
<StyledContainer> <StyledContainer>
<StyledTitle>{title}</StyledTitle> <StyledTitleContainer>
<StyledTitle>{title}</StyledTitle>
{addornment}
</StyledTitleContainer>
{description && <StyledDescription>{description}</StyledDescription>} {description && <StyledDescription>{description}</StyledDescription>}
</StyledContainer> </StyledContainer>
); );

View File

@ -10,8 +10,10 @@ export const GET_CURRENT_USER = gql`
firstName firstName
lastName lastName
avatarUrl avatarUrl
canImpersonate
workspaceMember { workspaceMember {
id id
allowImpersonation
workspace { workspace {
id id
domainName 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` export const UPDATE_PROFILE_PICTURE = gql`
mutation UploadProfilePicture($file: Upload!) { mutation UploadProfilePicture($file: Upload!) {
uploadProfilePicture(file: $file) uploadProfilePicture(file: $file)

View File

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

View File

@ -4,6 +4,7 @@ import { DeleteAccount } from '@/settings/profile/components/DeleteAccount';
import { EmailField } from '@/settings/profile/components/EmailField'; import { EmailField } from '@/settings/profile/components/EmailField';
import { NameFields } from '@/settings/profile/components/NameFields'; import { NameFields } from '@/settings/profile/components/NameFields';
import { ProfilePictureUploader } from '@/settings/profile/components/ProfilePictureUploader'; import { ProfilePictureUploader } from '@/settings/profile/components/ProfilePictureUploader';
import { ToggleField } from '@/settings/profile/components/ToggleField';
import { IconSettings } from '@/ui/icon'; import { IconSettings } from '@/ui/icon';
import { SubMenuTopBarContainer } from '@/ui/layout/components/SubMenuTopBarContainer'; import { SubMenuTopBarContainer } from '@/ui/layout/components/SubMenuTopBarContainer';
import { Section } from '@/ui/section/components/Section'; import { Section } from '@/ui/section/components/Section';
@ -44,6 +45,13 @@ export function SettingsProfile() {
/> />
<EmailField /> <EmailField />
</Section> </Section>
<Section>
<H2Title
title="Support"
addornment={<ToggleField />}
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."
/>
</Section>
<Section> <Section>
<DeleteAccount /> <DeleteAccount />
</Section> </Section>

View File

@ -16,9 +16,11 @@ export const mockedUsersData: Array<MockedUser> = [
firstName: 'Charles', firstName: 'Charles',
lastName: 'Test', lastName: 'Test',
avatarUrl: null, avatarUrl: null,
canImpersonate: false,
workspaceMember: { workspaceMember: {
__typename: 'WorkspaceMember', __typename: 'WorkspaceMember',
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b', id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b',
allowImpersonation: true,
workspace: { workspace: {
__typename: 'Workspace', __typename: 'Workspace',
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b', id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b',
@ -42,9 +44,11 @@ export const mockedUsersData: Array<MockedUser> = [
displayName: 'Felix Test', displayName: 'Felix Test',
firstName: 'Felix', firstName: 'Felix',
lastName: 'Test', lastName: 'Test',
canImpersonate: false,
workspaceMember: { workspaceMember: {
__typename: 'WorkspaceMember', __typename: 'WorkspaceMember',
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b', id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b',
allowImpersonation: true,
workspace: { workspace: {
__typename: 'Workspace', __typename: 'Workspace',
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b', id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b',
@ -72,9 +76,11 @@ export const mockedOnboardingUsersData: Array<MockedUser> = [
firstName: '', firstName: '',
lastName: '', lastName: '',
avatarUrl: null, avatarUrl: null,
canImpersonate: false,
workspaceMember: { workspaceMember: {
__typename: 'WorkspaceMember', __typename: 'WorkspaceMember',
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b', id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b',
allowImpersonation: true,
workspace: { workspace: {
__typename: 'Workspace', __typename: 'Workspace',
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b', id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b',
@ -99,9 +105,11 @@ export const mockedOnboardingUsersData: Array<MockedUser> = [
firstName: '', firstName: '',
lastName: '', lastName: '',
avatarUrl: null, avatarUrl: null,
canImpersonate: false,
workspaceMember: { workspaceMember: {
__typename: 'WorkspaceMember', __typename: 'WorkspaceMember',
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b', id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b',
allowImpersonation: true,
workspace: { workspace: {
__typename: 'Workspace', __typename: 'Workspace',
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b', id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b',

View File

@ -5,7 +5,7 @@ module.exports = {
tsconfigRootDir : __dirname, tsconfigRootDir : __dirname,
sourceType: 'module', sourceType: 'module',
}, },
plugins: ['@typescript-eslint/eslint-plugin', 'import'], plugins: ['@typescript-eslint/eslint-plugin', 'import', 'unused-imports'],
extends: [ extends: [
'plugin:@typescript-eslint/recommended', 'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended', 'plugin:prettier/recommended',
@ -74,5 +74,6 @@ module.exports = {
pathGroupsExcludedImportTypes: ['@nestjs/**'], pathGroupsExcludedImportTypes: ['@nestjs/**'],
}, },
], ],
'unused-imports/no-unused-imports': 'warn',
}, },
}; };

5
server/@types/common.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
type DeepPartial<T> = T extends object
? {
[P in keyof T]?: DeepPartial<T[P]>;
}
: T;

View File

@ -103,6 +103,7 @@
"eslint-config-prettier": "^8.3.0", "eslint-config-prettier": "^8.3.0",
"eslint-plugin-import": "^2.27.5", "eslint-plugin-import": "^2.27.5",
"eslint-plugin-prettier": "^4.0.0", "eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-unused-imports": "^3.0.0",
"jest": "28.1.3", "jest": "28.1.3",
"prettier": "^2.3.2", "prettier": "^2.3.2",
"prisma": "4.13.0", "prisma": "4.13.0",

View File

@ -1,5 +1,9 @@
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; 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'; import { Prisma } from '@prisma/client';
@ -7,6 +11,10 @@ import {
PrismaSelect, PrismaSelect,
PrismaSelector, PrismaSelector,
} from 'src/decorators/prisma-select.decorator'; } 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 { AuthTokens } from './dto/token.entity';
import { TokenService } from './services/token.service'; 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 { WorkspaceInviteHashValid } from './dto/workspace-invite-hash-valid.entity';
import { WorkspaceInviteHashValidInput } from './dto/workspace-invite-hash.input'; import { WorkspaceInviteHashValidInput } from './dto/workspace-invite-hash.input';
import { SignUpInput } from './dto/sign-up.input'; import { SignUpInput } from './dto/sign-up.input';
import { ImpersonateInput } from './dto/impersonate.input';
@Resolver() @Resolver()
export class AuthResolver { export class AuthResolver {
@ -96,4 +105,30 @@ export class AuthResolver {
return { tokens: tokens }; 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<Verify> {
// 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);
}
} }

View File

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

View File

@ -7,5 +7,5 @@ import { AuthTokens } from './token.entity';
@ObjectType() @ObjectType()
export class Verify extends AuthTokens { export class Verify extends AuthTokens {
@Field(() => User) @Field(() => User)
user: Partial<User>; user: DeepPartial<User>;
} }

View File

@ -165,4 +165,41 @@ export class AuthService {
return { isValid: !!workspace }; 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,
},
};
}
} }

View File

@ -20,6 +20,8 @@ import {
import { WorkspaceMemberService } from 'src/core/workspace/services/workspace-member.service'; import { WorkspaceMemberService } from 'src/core/workspace/services/workspace-member.service';
import { DeleteOneWorkspaceMemberArgs } from 'src/core/@generated/workspace-member/delete-one-workspace-member.args'; import { DeleteOneWorkspaceMemberArgs } from 'src/core/@generated/workspace-member/delete-one-workspace-member.args';
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard'; 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) @UseGuards(JwtAuthGuard)
@Resolver(() => WorkspaceMember) @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<Partial<WorkspaceMember>> {
return this.workspaceMemberService.update({
where: {
userId: user.id,
},
data: {
allowImpersonation,
},
select: prismaSelect.value,
});
}
@Mutation(() => WorkspaceMember) @Mutation(() => WorkspaceMember)
@UseGuards(AbilityGuard) @UseGuards(AbilityGuard)
@CheckAbilities(DeleteWorkspaceMemberAbilityHandler) @CheckAbilities(DeleteWorkspaceMemberAbilityHandler)

View File

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

View File

@ -65,39 +65,42 @@ generator nestgraphql {
model User { model User {
/// @Validator.IsString() /// @Validator.IsString()
/// @Validator.IsOptional() /// @Validator.IsOptional()
id String @id @default(uuid()) id String @id @default(uuid())
/// @Validator.IsString() /// @Validator.IsString()
/// @Validator.IsOptional() /// @Validator.IsOptional()
firstName String? firstName String?
/// @Validator.IsString() /// @Validator.IsString()
/// @Validator.IsOptional() /// @Validator.IsOptional()
lastName String? lastName String?
/// @Validator.IsEmail() /// @Validator.IsEmail()
/// @Validator.IsOptional() /// @Validator.IsOptional()
email String @unique email String @unique
/// @Validator.IsBoolean() /// @Validator.IsBoolean()
/// @Validator.IsOptional() /// @Validator.IsOptional()
emailVerified Boolean @default(false) emailVerified Boolean @default(false)
/// @Validator.IsString() /// @Validator.IsString()
/// @Validator.IsOptional() /// @Validator.IsOptional()
avatarUrl String? avatarUrl String?
/// @Validator.IsString() /// @Validator.IsString()
/// @Validator.IsOptional() /// @Validator.IsOptional()
locale String locale String
/// @Validator.IsString() /// @Validator.IsString()
/// @Validator.IsOptional() /// @Validator.IsOptional()
phoneNumber String? phoneNumber String?
/// @Validator.IsDate() /// @Validator.IsDate()
/// @Validator.IsOptional() /// @Validator.IsOptional()
lastSeen DateTime? lastSeen DateTime?
/// @Validator.IsBoolean() /// @Validator.IsBoolean()
/// @Validator.IsOptional() /// @Validator.IsOptional()
disabled Boolean @default(false) disabled Boolean @default(false)
/// @TypeGraphQL.omit(input: true, output: true) /// @TypeGraphQL.omit(input: true, output: true)
passwordHash String? passwordHash String?
/// @Validator.IsJSON() /// @Validator.IsJSON()
/// @Validator.IsOptional() /// @Validator.IsOptional()
metadata Json? metadata Json?
/// @Validator.IsBoolean()
/// @Validator.IsOptional()
canImpersonate Boolean @default(false)
/// @TypeGraphQL.omit(input: true) /// @TypeGraphQL.omit(input: true)
workspaceMember WorkspaceMember? workspaceMember WorkspaceMember?
@ -106,17 +109,17 @@ model User {
refreshTokens RefreshToken[] refreshTokens RefreshToken[]
comments Comment[] comments Comment[]
authoredActivities Activity[] @relation(name: "authoredActivities") authoredActivities Activity[] @relation(name: "authoredActivities")
assignedActivities Activity[] @relation(name: "assignedActivities") assignedActivities Activity[] @relation(name: "assignedActivities")
settings UserSettings @relation(fields: [settingsId], references: [id]) authoredAttachments Attachment[] @relation(name: "authoredAttachments")
settingsId String @unique settings UserSettings @relation(fields: [settingsId], references: [id])
settingsId String @unique
/// @TypeGraphQL.omit(input: true, output: true) /// @TypeGraphQL.omit(input: true, output: true)
deletedAt DateTime? deletedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
authoredAttachments Attachment[] @relation(name: "authoredAttachments")
@@map("users") @@map("users")
} }
@ -185,7 +188,10 @@ model Workspace {
model WorkspaceMember { model WorkspaceMember {
/// @Validator.IsString() /// @Validator.IsString()
/// @Validator.IsOptional() /// @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]) user User @relation(fields: [userId], references: [id])
userId String @unique userId String @unique

View File

@ -21,6 +21,7 @@
"strictBindCallApply": false, "strictBindCallApply": false,
"forceConsistentCasingInFileNames": false, "forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false, "noFallthroughCasesInSwitch": false,
"resolveJsonModule": true "resolveJsonModule": true,
"typeRoots": ["@types", "node_modules/@types"]
} }
} }

View File

@ -4776,6 +4776,18 @@ eslint-plugin-prettier@^4.0.0:
dependencies: dependencies:
prettier-linter-helpers "^1.0.0" 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: eslint-scope@5.1.1, eslint-scope@^5.1.1:
version "5.1.1" version "5.1.1"
resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz" resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz"