feat(twenty-front/workspace-menu): improve workspace menu (#10642)
New workspace menu
This commit is contained in:
@ -922,6 +922,7 @@ export type Mutation = {
|
||||
runWorkflowVersion: WorkflowRun;
|
||||
sendInvitations: SendInvitationsOutput;
|
||||
signUp: SignUpOutput;
|
||||
signUpInNewWorkspace: SignUpOutput;
|
||||
skipSyncEmailOnboardingStep: OnboardingStepSuccess;
|
||||
submitFormStep: Scalars['Boolean']['output'];
|
||||
syncRemoteTable: RemoteTable;
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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}
|
||||
>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -80,12 +80,12 @@ export const useDropdown = (dropdownId?: string) => {
|
||||
}
|
||||
},
|
||||
[
|
||||
dropdownId,
|
||||
isDropdownOpen,
|
||||
setIsDropdownOpen,
|
||||
setActiveDropdownFocusIdAndMemorizePrevious,
|
||||
dropdownId,
|
||||
scopeId,
|
||||
setHotkeyScopeAndMemorizePreviousScope,
|
||||
setActiveDropdownFocusIdAndMemorizePrevious,
|
||||
setIsDropdownOpen,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@ -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');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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;
|
||||
`;
|
||||
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const multiWorkspaceDropdownState = atom<
|
||||
'default' | 'workspaces-list' | 'themes'
|
||||
>({
|
||||
key: 'multiWorkspaceDropdownState',
|
||||
default: 'default',
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@ -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: {},
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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 &
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -281,9 +281,13 @@ export {
|
||||
IconUpload,
|
||||
IconUser,
|
||||
IconUserCircle,
|
||||
IconSwitchHorizontal,
|
||||
IconUserCog,
|
||||
IconUserPin,
|
||||
IconUserPlus,
|
||||
IconSunMoon,
|
||||
IconMoon,
|
||||
IconSun,
|
||||
IconUsers,
|
||||
IconVariable,
|
||||
IconVariablePlus,
|
||||
|
||||
Reference in New Issue
Block a user