feat(twenty-front/workspace-menu): improve workspace menu (#10642)

New workspace menu
This commit is contained in:
Antoine Moreaux
2025-03-17 16:31:31 +01:00
committed by GitHub
parent 78b3b7edab
commit bda835b9f8
28 changed files with 706 additions and 265 deletions

View File

@ -922,6 +922,7 @@ export type Mutation = {
runWorkflowVersion: WorkflowRun;
sendInvitations: SendInvitationsOutput;
signUp: SignUpOutput;
signUpInNewWorkspace: SignUpOutput;
skipSyncEmailOnboardingStep: OnboardingStepSuccess;
submitFormStep: Scalars['Boolean']['output'];
syncRemoteTable: RemoteTable;

View File

@ -842,6 +842,7 @@ export type Mutation = {
runWorkflowVersion: WorkflowRun;
sendInvitations: SendInvitationsOutput;
signUp: SignUpOutput;
signUpInNewWorkspace: SignUpOutput;
skipSyncEmailOnboardingStep: OnboardingStepSuccess;
submitFormStep: Scalars['Boolean'];
track: Analytics;
@ -2349,6 +2350,11 @@ export type SignUpMutationVariables = Exact<{
export type SignUpMutation = { __typename?: 'Mutation', signUp: { __typename?: 'SignUpOutput', loginToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, workspace: { __typename?: 'WorkspaceUrlsAndId', id: string, workspaceUrls: { __typename?: 'workspaceUrls', subdomainUrl: string, customUrl?: string | null } } } };
export type SignUpInNewWorkspaceMutationVariables = Exact<{ [key: string]: never; }>;
export type SignUpInNewWorkspaceMutation = { __typename?: 'Mutation', signUpInNewWorkspace: { __typename?: 'SignUpOutput', loginToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, workspace: { __typename?: 'WorkspaceUrlsAndId', id: string, workspaceUrls: { __typename?: 'workspaceUrls', subdomainUrl: string, customUrl?: string | null } } } };
export type UpdatePasswordViaResetTokenMutationVariables = Exact<{
token: Scalars['String'];
newPassword: Scalars['String'];
@ -3620,6 +3626,47 @@ export function useSignUpMutation(baseOptions?: Apollo.MutationHookOptions<SignU
export type SignUpMutationHookResult = ReturnType<typeof useSignUpMutation>;
export type SignUpMutationResult = Apollo.MutationResult<SignUpMutation>;
export type SignUpMutationOptions = Apollo.BaseMutationOptions<SignUpMutation, SignUpMutationVariables>;
export const SignUpInNewWorkspaceDocument = gql`
mutation SignUpInNewWorkspace {
signUpInNewWorkspace {
loginToken {
...AuthTokenFragment
}
workspace {
id
workspaceUrls {
subdomainUrl
customUrl
}
}
}
}
${AuthTokenFragmentFragmentDoc}`;
export type SignUpInNewWorkspaceMutationFn = Apollo.MutationFunction<SignUpInNewWorkspaceMutation, SignUpInNewWorkspaceMutationVariables>;
/**
* __useSignUpInNewWorkspaceMutation__
*
* To run a mutation, you first call `useSignUpInNewWorkspaceMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useSignUpInNewWorkspaceMutation` 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 [signUpInNewWorkspaceMutation, { data, loading, error }] = useSignUpInNewWorkspaceMutation({
* variables: {
* },
* });
*/
export function useSignUpInNewWorkspaceMutation(baseOptions?: Apollo.MutationHookOptions<SignUpInNewWorkspaceMutation, SignUpInNewWorkspaceMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<SignUpInNewWorkspaceMutation, SignUpInNewWorkspaceMutationVariables>(SignUpInNewWorkspaceDocument, options);
}
export type SignUpInNewWorkspaceMutationHookResult = ReturnType<typeof useSignUpInNewWorkspaceMutation>;
export type SignUpInNewWorkspaceMutationResult = Apollo.MutationResult<SignUpInNewWorkspaceMutation>;
export type SignUpInNewWorkspaceMutationOptions = Apollo.BaseMutationOptions<SignUpInNewWorkspaceMutation, SignUpInNewWorkspaceMutationVariables>;
export const UpdatePasswordViaResetTokenDocument = gql`
mutation UpdatePasswordViaResetToken($token: String!, $newPassword: String!) {
updatePasswordViaResetToken(

View File

@ -0,0 +1,18 @@
import { gql } from '@apollo/client';
export const SIGN_UP_IN_NEW_WORKSPACE = gql`
mutation SignUpInNewWorkspace {
signUpInNewWorkspace {
loginToken {
...AuthTokenFragment
}
workspace {
id
workspaceUrls {
subdomainUrl
customUrl
}
}
}
}
`;

View File

@ -4,8 +4,8 @@
import { useDebouncedCallback } from 'use-debounce';
export const useRedirect = () => {
const redirect = useDebouncedCallback((url: string) => {
window.location.href = url;
const redirect = useDebouncedCallback((url: string, target?: string) => {
window.open(url, target ?? '_self');
}, 1);
return {

View File

@ -12,9 +12,10 @@ export const useRedirectToWorkspaceDomain = () => {
baseUrl: string,
pathname?: string,
searchParams?: Record<string, string | boolean>,
target?: string,
) => {
if (!isMultiWorkspaceEnabled) return;
redirect(buildWorkspaceUrl(baseUrl, pathname, searchParams));
redirect(buildWorkspaceUrl(baseUrl, pathname, searchParams), target);
};
return {

View File

@ -42,10 +42,8 @@ export const AppNavigationDrawer = ({
label={t`Advanced:`}
/>
),
logo: '',
}
: {
logo: currentWorkspace?.logo ?? '',
title: currentWorkspace?.displayName ?? '',
children: <MainNavigationDrawerItems />,
footer: <SupportDropdown />,
@ -54,7 +52,6 @@ export const AppNavigationDrawer = ({
return (
<NavigationDrawer
className={className}
logo={drawerProps.logo}
title={drawerProps.title}
footer={drawerProps.footer}
>

View File

@ -5,7 +5,6 @@ import { NavigationDrawerSectionForObjectMetadataItems } from '@/object-metadata
import { SettingsPath } from '@/types/SettingsPath';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection';
import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo';
import { DEFAULT_WORKSPACE_NAME } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceName';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
@ -70,7 +69,6 @@ export const SignInAppNavigationDrawerMock = ({
<NavigationDrawer
className={className}
footer={footer}
logo={DEFAULT_WORKSPACE_LOGO}
title={DEFAULT_WORKSPACE_NAME}
>
{children}

View File

@ -19,9 +19,8 @@ const StyledHeader = styled.li`
font-weight: ${({ theme }) => theme.font.weight.medium};
border-top-left-radius: ${({ theme }) => theme.border.radius.sm};
border-top-right-radius: ${({ theme }) => theme.border.radius.sm};
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
padding: ${({ theme }) => theme.spacing(1)};
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
user-select: none;

View File

@ -80,12 +80,12 @@ export const useDropdown = (dropdownId?: string) => {
}
},
[
dropdownId,
isDropdownOpen,
setIsDropdownOpen,
setActiveDropdownFocusIdAndMemorizePrevious,
dropdownId,
scopeId,
setHotkeyScopeAndMemorizePreviousScope,
setActiveDropdownFocusIdAndMemorizePrevious,
setIsDropdownOpen,
],
);

View File

@ -0,0 +1,49 @@
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { MULTI_WORKSPACE_DROPDOWN_ID } from '@/ui/navigation/navigation-drawer/constants/MultiWorkspaceDropdownId';
import { NavigationDrawerHotKeyScope } from '@/ui/navigation/navigation-drawer/types/NavigationDrawerHotKeyScope';
import { multiWorkspaceDropdownState } from '@/ui/navigation/navigation-drawer/states/multiWorkspaceDropdownState';
import { useRecoilState } from 'recoil';
import { useMemo } from 'react';
import { MultiWorkspaceDropdownClickableComponent } from '@/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/MultiWorkspaceDropdownClickableComponent';
import { MultiWorkspaceDropdownDefaultComponents } from '@/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/MultiWorkspaceDropdownDefaultComponents';
import { MultiWorkspaceDropdownThemesComponents } from '@/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/MultiWorkspaceDropdownThemesComponents';
import { MultiWorkspaceDropdownWorkspacesListComponents } from '@/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/MultiWorkspaceDropdownWorkspacesListComponents';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
export const MultiWorkspaceDropdownButton = () => {
const [multiWorkspaceDropdown, setMultiWorkspaceDropdown] = useRecoilState(
multiWorkspaceDropdownState,
);
const DropdownComponents = useMemo(() => {
switch (multiWorkspaceDropdown) {
case 'themes':
return MultiWorkspaceDropdownThemesComponents;
case 'workspaces-list':
return MultiWorkspaceDropdownWorkspacesListComponents;
default:
return MultiWorkspaceDropdownDefaultComponents;
}
}, [multiWorkspaceDropdown]);
const { isDropdownOpen } = useDropdown(MULTI_WORKSPACE_DROPDOWN_ID);
return (
<Dropdown
dropdownId={MULTI_WORKSPACE_DROPDOWN_ID}
dropdownHotkeyScope={{
scope: NavigationDrawerHotKeyScope.MultiWorkspaceDropdownButton,
}}
dropdownOffset={{ y: 0, x: 0 }}
clickableComponent={
<MultiWorkspaceDropdownClickableComponent
isDropdownOpen={isDropdownOpen}
/>
}
dropdownComponents={<DropdownComponents />}
onClose={() => {
setMultiWorkspaceDropdown('default');
}}
/>
);
};

View File

@ -0,0 +1,46 @@
import { Avatar } from 'twenty-ui';
import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo';
import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper';
import {
StyledContainer,
StyledIconChevronDown,
StyledLabel,
} from '@/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/MultiWorkspacesDropdownStyles';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useRecoilValue } from 'recoil';
import { useTheme } from '@emotion/react';
import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded';
export const MultiWorkspaceDropdownClickableComponent = ({
isDropdownOpen,
}: {
isDropdownOpen: boolean;
}) => {
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const theme = useTheme();
const isNavigationDrawerExpanded = useRecoilValue(
isNavigationDrawerExpandedState,
);
return (
<StyledContainer
data-testid="workspace-dropdown"
isNavigationDrawerExpanded={isNavigationDrawerExpanded}
>
<Avatar
placeholder={currentWorkspace?.displayName || ''}
avatarUrl={currentWorkspace?.logo ?? DEFAULT_WORKSPACE_LOGO}
/>
<NavigationDrawerAnimatedCollapseWrapper>
<StyledLabel>{currentWorkspace?.displayName ?? ''}</StyledLabel>
</NavigationDrawerAnimatedCollapseWrapper>
<NavigationDrawerAnimatedCollapseWrapper>
<StyledIconChevronDown
isDropdownOpen={isDropdownOpen}
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
</NavigationDrawerAnimatedCollapseWrapper>
</StyledContainer>
);
};

View File

@ -0,0 +1,182 @@
import {
Avatar,
IconDotsVertical,
IconLogout,
IconPlus,
IconSwitchHorizontal,
IconUserPlus,
LightIconButton,
MenuItem,
MenuItemSelectAvatar,
UndecoratedLink,
} from 'twenty-ui';
import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { Workspaces, workspacesState } from '@/auth/states/workspaces';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { useLingui } from '@lingui/react/macro';
import { useBuildWorkspaceUrl } from '@/domain-manager/hooks/useBuildWorkspaceUrl';
import { multiWorkspaceDropdownState } from '@/ui/navigation/navigation-drawer/states/multiWorkspaceDropdownState';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { SettingsPath } from '@/types/SettingsPath';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { MULTI_WORKSPACE_DROPDOWN_ID } from '@/ui/navigation/navigation-drawer/constants/MultiWorkspaceDropdownId';
import { useAuth } from '@/auth/hooks/useAuth';
import { AppPath } from '@/types/AppPath';
import { useSignUpInNewWorkspaceMutation } from '~/generated/graphql';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { SelectHotkeyScope } from '@/ui/input/types/SelectHotkeyScope';
import { useColorScheme } from '@/ui/theme/hooks/useColorScheme';
import styled from '@emotion/styled';
const StyledDescription = styled.div`
color: ${({ theme }) => theme.font.color.light};
padding-left: ${({ theme }) => theme.spacing(1)};
`;
const StyledDropdownMenuItemsContainer = styled.div`
margin: ${({ theme }) => theme.spacing(1)} 0;
padding: 0 ${({ theme }) => theme.spacing(1)};
`;
export const MultiWorkspaceDropdownDefaultComponents = () => {
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const { t } = useLingui();
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const workspaces = useRecoilValue(workspacesState);
const { buildWorkspaceUrl } = useBuildWorkspaceUrl();
const { closeDropdown } = useDropdown(MULTI_WORKSPACE_DROPDOWN_ID);
const { signOut } = useAuth();
const { enqueueSnackBar } = useSnackBar();
const { colorScheme, colorSchemeList } = useColorScheme();
const [signUpInNewWorkspaceMutation] = useSignUpInNewWorkspaceMutation();
const setMultiWorkspaceDropdownState = useSetRecoilState(
multiWorkspaceDropdownState,
);
const handleChange = async (workspace: Workspaces[0]) => {
redirectToWorkspaceDomain(getWorkspaceUrl(workspace.workspaceUrls));
};
const createWorkspace = () => {
signUpInNewWorkspaceMutation({
onCompleted: (data) => {
return redirectToWorkspaceDomain(
getWorkspaceUrl(data.signUpInNewWorkspace.workspace.workspaceUrls),
AppPath.Verify,
{
loginToken: data.signUpInNewWorkspace.loginToken.token,
},
'_blank',
);
},
onError: (error: Error) => {
enqueueSnackBar(error.message, {
variant: SnackBarVariant.Error,
});
},
});
};
return (
<>
<DropdownMenuHeader
StartAvatar={
<Avatar
placeholder={currentWorkspace?.displayName || ''}
avatarUrl={currentWorkspace?.logo ?? DEFAULT_WORKSPACE_LOGO}
/>
}
DropdownOnEndIcon={
<Dropdown
clickableComponent={
<LightIconButton
Icon={IconDotsVertical}
size="small"
accent="tertiary"
/>
}
dropdownId={'multi-workspace-dropdown-context-menu'}
dropdownHotkeyScope={{ scope: SelectHotkeyScope.Select }}
dropdownComponents={
<DropdownMenuItemsContainer>
<MenuItem
LeftIcon={IconPlus}
text={t`Create Workspace`}
onClick={createWorkspace}
/>
</DropdownMenuItemsContainer>
}
/>
}
>
{currentWorkspace?.displayName}
</DropdownMenuHeader>
<StyledDropdownMenuItemsContainer>
{workspaces
.filter(({ id }) => id !== currentWorkspace?.id)
.slice(0, 3)
.map((workspace) => (
<UndecoratedLink
key={workspace.id}
to={buildWorkspaceUrl(getWorkspaceUrl(workspace.workspaceUrls))}
onClick={(event) => {
event?.preventDefault();
handleChange(workspace);
}}
>
<MenuItemSelectAvatar
text={workspace.displayName ?? '(No name)'}
avatar={
<Avatar
placeholder={workspace.displayName || ''}
avatarUrl={workspace.logo ?? DEFAULT_WORKSPACE_LOGO}
/>
}
selected={false}
/>
</UndecoratedLink>
))}
{workspaces.length > 4 && (
<MenuItem
LeftIcon={IconSwitchHorizontal}
text={t`Other workspaces`}
onClick={() => setMultiWorkspaceDropdownState('workspaces-list')}
hasSubMenu={true}
/>
)}
</StyledDropdownMenuItemsContainer>
{workspaces.length > 1 && <DropdownMenuSeparator />}
<StyledDropdownMenuItemsContainer>
<MenuItem
LeftIcon={colorSchemeList.find(({ id }) => id === colorScheme)?.icon}
text={
<>
{t`Theme `}
<StyledDescription>{` · ${colorScheme}`}</StyledDescription>
</>
}
hasSubMenu={true}
onClick={() => setMultiWorkspaceDropdownState('themes')}
/>
<UndecoratedLink
to={getSettingsPath(SettingsPath.WorkspaceMembersPage)}
onClick={closeDropdown}
>
<MenuItem LeftIcon={IconUserPlus} text={t`Invite user`} />
</UndecoratedLink>
<MenuItem LeftIcon={IconLogout} text={t`Log out`} onClick={signOut} />
</StyledDropdownMenuItemsContainer>
</>
);
};

View File

@ -0,0 +1,37 @@
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { IconCheck, IconChevronLeft, MenuItem } from 'twenty-ui';
import { useLingui } from '@lingui/react/macro';
import { multiWorkspaceDropdownState } from '@/ui/navigation/navigation-drawer/states/multiWorkspaceDropdownState';
import { useSetRecoilState } from 'recoil';
import { useColorScheme } from '@/ui/theme/hooks/useColorScheme';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
export const MultiWorkspaceDropdownThemesComponents = () => {
const { t } = useLingui();
const { setColorScheme, colorScheme, colorSchemeList } = useColorScheme();
const setMultiWorkspaceDropdownState = useSetRecoilState(
multiWorkspaceDropdownState,
);
return (
<DropdownMenuItemsContainer>
<DropdownMenuHeader
StartIcon={IconChevronLeft}
onStartIconClick={() => setMultiWorkspaceDropdownState('default')}
>
{t`Theme`}
</DropdownMenuHeader>
{colorSchemeList.map((theme) => (
<MenuItem
LeftIcon={theme.icon}
/* eslint-disable-next-line lingui/no-expression-in-message */
text={t`${theme.id}`}
onClick={() => setColorScheme(theme.id)}
RightIcon={theme.id === colorScheme ? IconCheck : undefined}
/>
))}
</DropdownMenuItemsContainer>
);
};

View File

@ -0,0 +1,84 @@
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import {
Avatar,
IconChevronLeft,
MenuItemSelectAvatar,
UndecoratedLink,
} from 'twenty-ui';
import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { Workspaces, workspacesState } from '@/auth/states/workspaces';
import { useBuildWorkspaceUrl } from '@/domain-manager/hooks/useBuildWorkspaceUrl';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { useLingui } from '@lingui/react/macro';
import { multiWorkspaceDropdownState } from '@/ui/navigation/navigation-drawer/states/multiWorkspaceDropdownState';
import { useState } from 'react';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
export const MultiWorkspaceDropdownWorkspacesListComponents = () => {
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const workspaces = useRecoilValue(workspacesState);
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const { buildWorkspaceUrl } = useBuildWorkspaceUrl();
const { t } = useLingui();
const handleChange = async (workspace: Workspaces[0]) => {
redirectToWorkspaceDomain(getWorkspaceUrl(workspace.workspaceUrls));
};
const setMultiWorkspaceDropdownState = useSetRecoilState(
multiWorkspaceDropdownState,
);
const [searchValue, setSearchValue] = useState('');
return (
<DropdownMenuItemsContainer>
<DropdownMenuHeader
StartIcon={IconChevronLeft}
onStartIconClick={() => setMultiWorkspaceDropdownState('default')}
>
{t`Other workspaces`}
</DropdownMenuHeader>
<DropdownMenuSearchInput
placeholder={t`Search`}
autoFocus
onChange={(event) => {
setSearchValue(event.target.value);
}}
/>
<DropdownMenuSeparator />
{workspaces
.filter(
(workspace) =>
workspace.id !== currentWorkspace?.id &&
workspace.displayName
?.toLowerCase()
.includes(searchValue.toLowerCase()),
)
.map((workspace) => (
<UndecoratedLink
key={workspace.id}
to={buildWorkspaceUrl(getWorkspaceUrl(workspace.workspaceUrls))}
onClick={(event) => {
event?.preventDefault();
handleChange(workspace);
}}
>
<MenuItemSelectAvatar
text={workspace.displayName ?? '(No name)'}
avatar={
<Avatar
placeholder={workspace.displayName || ''}
avatarUrl={workspace.logo ?? DEFAULT_WORKSPACE_LOGO}
/>
}
selected={currentWorkspace?.id === workspace.id}
/>
</UndecoratedLink>
))}
</DropdownMenuItemsContainer>
);
};

View File

@ -0,0 +1,43 @@
import { IconChevronDown } from 'twenty-ui';
import styled from '@emotion/styled';
export const StyledContainer = styled.div<{
isNavigationDrawerExpanded: boolean;
}>`
align-items: center;
cursor: pointer;
color: ${({ theme }) => theme.font.color.primary};
border-radius: ${({ theme }) => theme.border.radius.sm};
border: 1px solid transparent;
display: flex;
justify-content: space-between;
height: ${({ theme, isNavigationDrawerExpanded }) =>
isNavigationDrawerExpanded ? theme.spacing(5) : theme.spacing(4)};
padding: calc(${({ theme }) => theme.spacing(1)} - 1px);
width: ${({ isNavigationDrawerExpanded }) =>
isNavigationDrawerExpanded ? '100%' : 'auto'};
gap: ${({ theme, isNavigationDrawerExpanded }) =>
isNavigationDrawerExpanded ? theme.spacing(1) : '0'};
&:hover {
background-color: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium};
}
`;
export const StyledLabel = styled.div`
align-items: center;
display: flex;
font-weight: ${({ theme }) => theme.font.weight.medium};
`;
export const StyledIconChevronDown = styled(IconChevronDown)<{
disabled?: boolean;
isDropdownOpen?: boolean;
}>`
align-items: center;
color: ${({ disabled, theme }) =>
disabled ? theme.font.color.extraLight : theme.font.color.tertiary};
display: flex;
rotate: ${({ isDropdownOpen }) => (isDropdownOpen ? '-180deg' : '0deg')};
transition: rotate 0.1s ease-in-out;
`;

View File

@ -1,130 +0,0 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { Workspaces } from '@/auth/states/workspaces';
import { useBuildWorkspaceUrl } from '@/domain-manager/hooks/useBuildWorkspaceUrl';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper';
import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo';
import { MULTI_WORKSPACE_DROPDOWN_ID } from '@/ui/navigation/navigation-drawer/constants/MulitWorkspaceDropdownId';
import { NavigationDrawerHotKeyScope } from '@/ui/navigation/navigation-drawer/types/NavigationDrawerHotKeyScope';
import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useRecoilState, useRecoilValue } from 'recoil';
import {
Avatar,
IconChevronDown,
MenuItemSelectAvatar,
UndecoratedLink,
} from 'twenty-ui';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
const StyledContainer = styled.div<{ isNavigationDrawerExpanded: boolean }>`
align-items: center;
cursor: pointer;
color: ${({ theme }) => theme.font.color.primary};
border-radius: ${({ theme }) => theme.border.radius.sm};
border: 1px solid transparent;
display: flex;
justify-content: space-between;
height: ${({ theme, isNavigationDrawerExpanded }) =>
isNavigationDrawerExpanded ? theme.spacing(5) : theme.spacing(4)};
padding: calc(${({ theme }) => theme.spacing(1)} - 1px);
width: ${({ isNavigationDrawerExpanded }) =>
isNavigationDrawerExpanded ? '100%' : 'auto'};
gap: ${({ theme, isNavigationDrawerExpanded }) =>
isNavigationDrawerExpanded ? theme.spacing(1) : '0'};
&:hover {
background-color: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium};
}
`;
const StyledLabel = styled.div`
align-items: center;
display: flex;
font-weight: ${({ theme }) => theme.font.weight.medium};
`;
const StyledIconChevronDown = styled(IconChevronDown)<{ disabled?: boolean }>`
align-items: center;
color: ${({ disabled, theme }) =>
disabled ? theme.font.color.extraLight : theme.font.color.tertiary};
display: flex;
`;
type MultiWorkspaceDropdownButtonProps = {
workspaces: Workspaces;
};
export const MultiWorkspaceDropdownButton = ({
workspaces,
}: MultiWorkspaceDropdownButtonProps) => {
const theme = useTheme();
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const { buildWorkspaceUrl } = useBuildWorkspaceUrl();
const handleChange = async (workspace: Workspaces[0]) => {
redirectToWorkspaceDomain(getWorkspaceUrl(workspace.workspaceUrls));
};
const [isNavigationDrawerExpanded] = useRecoilState(
isNavigationDrawerExpandedState,
);
return (
<Dropdown
dropdownId={MULTI_WORKSPACE_DROPDOWN_ID}
dropdownHotkeyScope={{
scope: NavigationDrawerHotKeyScope.MultiWorkspaceDropdownButton,
}}
clickableComponent={
<StyledContainer
data-testid="workspace-dropdown"
isNavigationDrawerExpanded={isNavigationDrawerExpanded}
>
<Avatar
placeholder={currentWorkspace?.displayName || ''}
avatarUrl={currentWorkspace?.logo ?? DEFAULT_WORKSPACE_LOGO}
/>
<NavigationDrawerAnimatedCollapseWrapper>
<StyledLabel>{currentWorkspace?.displayName ?? ''}</StyledLabel>
</NavigationDrawerAnimatedCollapseWrapper>
<NavigationDrawerAnimatedCollapseWrapper>
<StyledIconChevronDown
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
</NavigationDrawerAnimatedCollapseWrapper>
</StyledContainer>
}
dropdownComponents={
<DropdownMenuItemsContainer>
{workspaces.map((workspace) => (
<UndecoratedLink
key={workspace.id}
to={buildWorkspaceUrl(getWorkspaceUrl(workspace.workspaceUrls))}
onClick={(event) => {
event?.preventDefault();
handleChange(workspace);
}}
>
<MenuItemSelectAvatar
text={workspace.displayName ?? '(No name)'}
avatar={
<Avatar
placeholder={workspace.displayName || ''}
avatarUrl={workspace.logo ?? DEFAULT_WORKSPACE_LOGO}
/>
}
selected={currentWorkspace?.id === workspace.id}
/>
</UndecoratedLink>
))}
</DropdownMenuItemsContainer>
}
/>
);
};

View File

@ -19,7 +19,6 @@ export type NavigationDrawerProps = {
children: ReactNode;
className?: string;
footer?: ReactNode;
logo?: string;
title: string;
};
@ -64,7 +63,6 @@ export const NavigationDrawer = ({
children,
className,
footer,
logo,
title,
}: NavigationDrawerProps) => {
const [isHovered, setIsHovered] = useState(false);
@ -113,11 +111,7 @@ export const NavigationDrawer = ({
{isSettingsDrawer && title ? (
!isMobile && <NavigationDrawerBackButton title={title} />
) : (
<NavigationDrawerHeader
name={title}
logo={logo || ''}
showCollapseButton={isHovered}
/>
<NavigationDrawerHeader showCollapseButton={isHovered} />
)}
<StyledItemsContainer isSettings={isSettingsDrawer}>
{children}

View File

@ -1,14 +1,10 @@
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { workspacesState } from '@/auth/states/workspaces';
import { MultiWorkspaceDropdownButton } from '@/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdownButton';
import { MultiWorkspaceDropdownButton } from '@/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/MultiWorkspaceDropdownButton';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper';
import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded';
import { Avatar } from 'twenty-ui';
import { NavigationDrawerCollapseButton } from './NavigationDrawerCollapseButton';
const StyledContainer = styled.div`
@ -18,18 +14,6 @@ const StyledContainer = styled.div`
user-select: none;
`;
const StyledSingleWorkspaceContainer = styled(StyledContainer)`
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(1)};
`;
const StyledName = styled.div`
color: ${({ theme }) => theme.font.color.primary};
font-family: 'Inter';
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.medium};
`;
const StyledNavigationDrawerCollapseButton = styled(
NavigationDrawerCollapseButton,
)<{ show?: boolean }>`
@ -39,38 +23,21 @@ const StyledNavigationDrawerCollapseButton = styled(
`;
type NavigationDrawerHeaderProps = {
name: string;
logo: string;
showCollapseButton: boolean;
};
export const NavigationDrawerHeader = ({
name,
logo,
showCollapseButton,
}: NavigationDrawerHeaderProps) => {
const isMobile = useIsMobile();
const workspaces = useRecoilValue(workspacesState);
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
const isNavigationDrawerExpanded = useRecoilValue(
isNavigationDrawerExpandedState,
);
const isMultiWorkspace = isMultiWorkspaceEnabled && workspaces.length > 1;
return (
<StyledContainer>
{isMultiWorkspace ? (
<MultiWorkspaceDropdownButton workspaces={workspaces} />
) : (
<StyledSingleWorkspaceContainer>
<Avatar placeholder={name} avatarUrl={logo} />
<NavigationDrawerAnimatedCollapseWrapper>
<StyledName>{name}</StyledName>
</NavigationDrawerAnimatedCollapseWrapper>
</StyledSingleWorkspaceContainer>
)}
<MultiWorkspaceDropdownButton />
{!isMobile && isNavigationDrawerExpanded && (
<StyledNavigationDrawerCollapseButton
direction="left"

View File

@ -0,0 +1,8 @@
import { atom } from 'recoil';
export const multiWorkspaceDropdownState = atom<
'default' | 'workspaces-list' | 'themes'
>({
key: 'multiWorkspaceDropdownState',
default: 'default',
});

View File

@ -5,6 +5,7 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMembe
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { ColorScheme } from '@/workspace-member/types/WorkspaceMember';
import { IconComponent, IconMoon, IconSun, IconSunMoon } from 'twenty-ui';
export const useColorScheme = () => {
const [currentWorkspaceMember, setCurrentWorkspaceMember] = useRecoilState(
@ -45,8 +46,27 @@ export const useColorScheme = () => {
],
);
const colorSchemeList: Array<{
id: ColorScheme;
icon: IconComponent;
}> = [
{
id: 'System',
icon: IconSunMoon,
},
{
id: 'Dark',
icon: IconMoon,
},
{
id: 'Light',
icon: IconSun,
},
];
return {
colorScheme,
setColorScheme,
colorSchemeList,
};
};

View File

@ -12,6 +12,7 @@ import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { AuthResolver } from './auth.resolver';
@ -64,6 +65,10 @@ describe('AuthResolver', () => {
provide: RenewTokenService,
useValue: {},
},
{
provide: SignInUpService,
useValue: {},
},
{
provide: ApiKeyService,
useValue: {},

View File

@ -51,6 +51,7 @@ import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { SettingsPermissions } from 'src/engine/metadata-modules/permissions/constants/settings-permissions.constants';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { GetAuthTokensFromLoginTokenInput } from './dto/get-auth-tokens-from-login-token.input';
import { GetLoginTokenFromCredentialsInput } from './dto/get-login-token-from-credentials.input';
@ -75,6 +76,7 @@ export class AuthResolver {
private apiKeyService: ApiKeyService,
private resetPasswordService: ResetPasswordService,
private loginTokenService: LoginTokenService,
private signInUpService: SignInUpService,
private transientTokenService: TransientTokenService,
private emailVerificationService: EmailVerificationService,
// private oauthService: OAuthService,
@ -258,6 +260,28 @@ export class AuthResolver {
};
}
@Mutation(() => SignUpOutput)
async signUpInNewWorkspace(
@AuthUser() currentUser: User,
): Promise<SignUpOutput> {
const { user, workspace } = await this.signInUpService.signUpOnNewWorkspace(
{ type: 'existingUser', existingUser: currentUser },
);
const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
workspace.id,
);
return {
loginToken,
workspace: {
id: workspace.id,
workspaceUrls: this.domainManagerService.getWorkspaceUrls(workspace),
},
};
}
// @Mutation(() => ExchangeAuthCode)
// async exchangeAuthorizationCode(
// @Args() exchangeAuthCodeInput: ExchangeAuthCodeInput,

View File

@ -9,7 +9,7 @@ import { render } from '@react-email/render';
import { addMilliseconds } from 'date-fns';
import ms from 'ms';
import { PasswordUpdateNotifyEmail } from 'twenty-emails';
import { APP_LOCALES } from 'twenty-shared';
import { APP_LOCALES, isDefined } from 'twenty-shared';
import { Repository } from 'typeorm';
import { NodeEnvironment } from 'src/engine/core-modules/environment/interfaces/node-environment.interface';
@ -174,35 +174,50 @@ export class AuthService {
return user;
}
private async validatePassword(
userData: ExistingUserOrNewUser['userData'],
authParams: Extract<
AuthProviderWithPasswordType['authParams'],
{ provider: 'password' }
>,
) {
if (userData.type === 'newUser') {
userData.newUserPayload.passwordHash =
await this.signInUpService.generateHash(authParams.password);
}
if (userData.type === 'existingUser') {
await this.signInUpService.validatePassword({
password: authParams.password,
passwordHash: userData.existingUser.passwordHash,
});
}
}
private async isAuthProviderEnabledOrThrow(
userData: ExistingUserOrNewUser['userData'],
authParams: AuthProviderWithPasswordType['authParams'],
workspace: Workspace | undefined | null,
) {
if (authParams.provider === 'password') {
await this.validatePassword(userData, authParams);
}
if (isDefined(workspace)) {
workspaceValidator.isAuthEnabledOrThrow(authParams.provider, workspace);
}
}
async signInUp(
params: SignInUpBaseParams &
ExistingUserOrNewUser &
AuthProviderWithPasswordType,
) {
if (
params.authParams.provider === 'password' &&
params.userData.type === 'newUser'
) {
params.userData.newUserPayload.passwordHash =
await this.signInUpService.generateHash(params.authParams.password);
}
if (
params.authParams.provider === 'password' &&
params.userData.type === 'existingUser'
) {
await this.signInUpService.validatePassword({
password: params.authParams.password,
passwordHash: params.userData.existingUser.passwordHash,
});
}
if (params.workspace) {
workspaceValidator.isAuthEnabledOrThrow(
params.authParams.provider,
params.workspace,
);
}
await this.isAuthProviderEnabledOrThrow(
params.userData,
params.authParams,
params.workspace,
);
if (params.userData.type === 'newUser') {
const partialUserWithPicture =

View File

@ -261,8 +261,11 @@ describe('SignInUpService', () => {
.mockResolvedValue('a-subdomain');
jest
.spyOn(UserRepository, 'save')
.mockResolvedValue({ id: 'newUserId' } as User);
jest.spyOn(userWorkspaceService, 'create').mockResolvedValue({} as any);
jest
.spyOn(userWorkspaceService, 'create')
.mockResolvedValue({} as UserWorkspace);
const result = await service.signInUp(params);
@ -334,6 +337,35 @@ describe('SignInUpService', () => {
);
});
it('should handle signup for existing user on new workspace', async () => {
const params: SignInUpBaseParams &
ExistingUserOrPartialUserWithPicture &
AuthProviderWithPasswordType = {
workspace: null,
authParams: { provider: 'password', password: 'validPassword' },
userData: {
type: 'existingUser',
existingUser: { email: 'existinguser@example.com' } as User,
},
};
jest.spyOn(environmentService, 'get').mockReturnValue(false);
jest.spyOn(WorkspaceRepository, 'count').mockResolvedValue(0);
jest.spyOn(WorkspaceRepository, 'create').mockReturnValue({} as Workspace);
jest.spyOn(WorkspaceRepository, 'save').mockResolvedValue({
id: 'newWorkspaceId',
activationStatus: WorkspaceActivationStatus.PENDING_CREATION,
} as Workspace);
jest.spyOn(userWorkspaceService, 'create').mockResolvedValue({} as any);
const result = await service.signInUp(params);
expect(result.workspace).toBeDefined();
expect(result.user).toBeDefined();
expect(WorkspaceRepository.create).toHaveBeenCalled();
expect(WorkspaceRepository.save).toHaveBeenCalled();
});
it('should assign default role when permissions are enabled', async () => {
const params: SignInUpBaseParams &
ExistingUserOrPartialUserWithPicture &

View File

@ -90,7 +90,6 @@ export class SignInUpService {
ExistingUserOrPartialUserWithPicture &
AuthProviderWithPasswordType,
) {
// with personal invitation flow
if (params.workspace && params.invitation) {
return {
workspace: params.workspace,
@ -110,14 +109,7 @@ export class SignInUpService {
return { user: updatedUser, workspace: params.workspace };
}
if (params.userData.type === 'newUserWithPicture') {
return await this.signUpOnNewWorkspace(
params.userData.newUserWithPicture,
);
}
// should never happen.
throw new Error('Invalid sign in up params');
return await this.signUpOnNewWorkspace(params.userData);
}
async generateHash(password: string) {
@ -200,24 +192,6 @@ export class SignInUpService {
return updatedUser;
}
private async persistNewUser(
newUser: PartialUserWithPicture,
workspace: Workspace,
) {
const imagePath = await this.uploadPicture(newUser.picture, workspace.id);
delete newUser.picture;
const userToCreate = this.userRepository.create({
...newUser,
defaultAvatarUrl: imagePath,
canAccessFullAdminPanel: false,
canImpersonate: false,
} as Partial<User>);
return await this.userRepository.save(userToCreate);
}
private async throwIfWorkspaceIsNotReadyForSignInUp(
workspace: Workspace,
user: ExistingUserOrPartialUserWithPicture,
@ -254,9 +228,10 @@ export class SignInUpService {
const currentUser =
params.userData.type === 'newUserWithPicture'
? await this.persistNewUser(
? await this.saveNewUser(
params.userData.newUserWithPicture,
params.workspace,
params.workspace.id,
{ canAccessFullAdminPanel: false, canImpersonate: false },
)
: params.userData.existingUser;
@ -299,14 +274,42 @@ export class SignInUpService {
}
}
async signUpOnNewWorkspace(partialUserWithPicture: PartialUserWithPicture) {
const user: PartialUserWithPicture = {
...partialUserWithPicture,
canImpersonate: false,
canAccessFullAdminPanel: false,
};
private async saveNewUser(
newUserWithPicture: PartialUserWithPicture,
workspaceId: string,
{
canImpersonate,
canAccessFullAdminPanel,
}: {
canImpersonate: boolean;
canAccessFullAdminPanel: boolean;
},
) {
const defaultAvatarUrl = await this.uploadPicture(
newUserWithPicture.picture,
workspaceId,
);
const userCreated = this.userRepository.create({
...newUserWithPicture,
defaultAvatarUrl,
canImpersonate,
canAccessFullAdminPanel,
});
if (!user.email) {
return await this.userRepository.save(userCreated);
}
async signUpOnNewWorkspace(
userData: ExistingUserOrPartialUserWithPicture['userData'],
) {
let canImpersonate = false;
let canAccessFullAdminPanel = false;
const email =
userData.type === 'newUserWithPicture'
? userData.newUserWithPicture.email
: userData.existingUser.email;
if (!email) {
throw new AuthException(
'Email is required',
AuthExceptionCode.INVALID_INPUT,
@ -317,8 +320,8 @@ export class SignInUpService {
const workspacesCount = await this.workspaceRepository.count();
// if the workspace doesn't exist it means it's the first user of the workspace
user.canImpersonate = true;
user.canAccessFullAdminPanel = true;
canImpersonate = true;
canAccessFullAdminPanel = true;
// let the creation of the first workspace
if (workspacesCount > 0) {
@ -329,7 +332,7 @@ export class SignInUpService {
}
}
const logoUrl = `${TWENTY_ICONS_BASE_URL}/${getDomainNameByEmail(user.email)}`;
const logoUrl = `${TWENTY_ICONS_BASE_URL}/${getDomainNameByEmail(email)}`;
const isLogoUrlValid = async () => {
try {
return (
@ -342,7 +345,7 @@ export class SignInUpService {
};
const logo =
isWorkEmail(user.email) && (await isLogoUrlValid()) ? logoUrl : undefined;
isWorkEmail(email) && (await isLogoUrlValid()) ? logoUrl : undefined;
const workspaceToCreate = this.workspaceRepository.create({
subdomain: await this.domainManagerService.generateSubdomain(),
@ -354,25 +357,24 @@ export class SignInUpService {
const workspace = await this.workspaceRepository.save(workspaceToCreate);
user.defaultAvatarUrl = await this.uploadPicture(
partialUserWithPicture.picture,
workspace.id,
);
const user =
userData.type === 'existingUser'
? userData.existingUser
: await this.saveNewUser(userData.newUserWithPicture, workspace.id, {
canImpersonate,
canAccessFullAdminPanel,
});
const userCreated = this.userRepository.create(user);
await this.userWorkspaceService.create(user.id, workspace.id);
const newUser = await this.userRepository.save(userCreated);
await this.userWorkspaceService.create(newUser.id, workspace.id);
await this.activateOnboardingForUser(newUser, workspace);
await this.activateOnboardingForUser(user, workspace);
await this.onboardingService.setOnboardingInviteTeamPending({
workspaceId: workspace.id,
value: true,
});
return { user: newUser, workspace };
return { user, workspace };
}
async uploadPicture(

View File

@ -203,12 +203,10 @@ export class WorkspaceResolver {
return null;
}
const role = await this.roleService.getRoleById(
return await this.roleService.getRoleById(
workspace.defaultRoleId,
workspace.id,
);
return role;
}
@ResolveField(() => BillingSubscription, { nullable: true })

View File

@ -281,9 +281,13 @@ export {
IconUpload,
IconUser,
IconUserCircle,
IconSwitchHorizontal,
IconUserCog,
IconUserPin,
IconUserPlus,
IconSunMoon,
IconMoon,
IconSun,
IconUsers,
IconVariable,
IconVariablePlus,