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