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:
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|||||||
@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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',
|
||||||
}
|
}
|
||||||
|
|||||||
63
front/src/modules/ui/input/toggle/components/Toggle.tsx
Normal file
63
front/src/modules/ui/input/toggle/components/Toggle.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
57
front/src/pages/impersonate/Impersonate.tsx
Normal file
57
front/src/pages/impersonate/Impersonate.tsx
Normal 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 <></>;
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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
5
server/@types/common.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
type DeepPartial<T> = T extends object
|
||||||
|
? {
|
||||||
|
[P in keyof T]?: DeepPartial<T[P]>;
|
||||||
|
}
|
||||||
|
: T;
|
||||||
@ -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",
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
11
server/src/core/auth/dto/impersonate.input.ts
Normal file
11
server/src/core/auth/dto/impersonate.input.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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
|
||||||
|
|||||||
@ -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"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user