diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index aa3e3e75b..6db6c27cc 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -922,6 +922,7 @@ export type Mutation = { runWorkflowVersion: WorkflowRun; sendInvitations: SendInvitationsOutput; signUp: SignUpOutput; + signUpInNewWorkspace: SignUpOutput; skipSyncEmailOnboardingStep: OnboardingStepSuccess; submitFormStep: Scalars['Boolean']['output']; syncRemoteTable: RemoteTable; diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index f1a711d5b..744d044ff 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -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; export type SignUpMutationResult = Apollo.MutationResult; export type SignUpMutationOptions = Apollo.BaseMutationOptions; +export const SignUpInNewWorkspaceDocument = gql` + mutation SignUpInNewWorkspace { + signUpInNewWorkspace { + loginToken { + ...AuthTokenFragment + } + workspace { + id + workspaceUrls { + subdomainUrl + customUrl + } + } + } +} + ${AuthTokenFragmentFragmentDoc}`; +export type SignUpInNewWorkspaceMutationFn = Apollo.MutationFunction; + +/** + * __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) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(SignUpInNewWorkspaceDocument, options); + } +export type SignUpInNewWorkspaceMutationHookResult = ReturnType; +export type SignUpInNewWorkspaceMutationResult = Apollo.MutationResult; +export type SignUpInNewWorkspaceMutationOptions = Apollo.BaseMutationOptions; export const UpdatePasswordViaResetTokenDocument = gql` mutation UpdatePasswordViaResetToken($token: String!, $newPassword: String!) { updatePasswordViaResetToken( diff --git a/packages/twenty-front/src/modules/auth/graphql/mutations/signUpInNewWorkspace.ts b/packages/twenty-front/src/modules/auth/graphql/mutations/signUpInNewWorkspace.ts new file mode 100644 index 000000000..8f08121d2 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/graphql/mutations/signUpInNewWorkspace.ts @@ -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 + } + } + } + } +`; diff --git a/packages/twenty-front/src/modules/domain-manager/hooks/useRedirect.ts b/packages/twenty-front/src/modules/domain-manager/hooks/useRedirect.ts index 241c034fd..80489add2 100644 --- a/packages/twenty-front/src/modules/domain-manager/hooks/useRedirect.ts +++ b/packages/twenty-front/src/modules/domain-manager/hooks/useRedirect.ts @@ -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 { diff --git a/packages/twenty-front/src/modules/domain-manager/hooks/useRedirectToWorkspaceDomain.ts b/packages/twenty-front/src/modules/domain-manager/hooks/useRedirectToWorkspaceDomain.ts index a1317ac50..1e7795dc8 100644 --- a/packages/twenty-front/src/modules/domain-manager/hooks/useRedirectToWorkspaceDomain.ts +++ b/packages/twenty-front/src/modules/domain-manager/hooks/useRedirectToWorkspaceDomain.ts @@ -12,9 +12,10 @@ export const useRedirectToWorkspaceDomain = () => { baseUrl: string, pathname?: string, searchParams?: Record, + target?: string, ) => { if (!isMultiWorkspaceEnabled) return; - redirect(buildWorkspaceUrl(baseUrl, pathname, searchParams)); + redirect(buildWorkspaceUrl(baseUrl, pathname, searchParams), target); }; return { diff --git a/packages/twenty-front/src/modules/navigation/components/AppNavigationDrawer.tsx b/packages/twenty-front/src/modules/navigation/components/AppNavigationDrawer.tsx index 170398afd..aa6871fbc 100644 --- a/packages/twenty-front/src/modules/navigation/components/AppNavigationDrawer.tsx +++ b/packages/twenty-front/src/modules/navigation/components/AppNavigationDrawer.tsx @@ -42,10 +42,8 @@ export const AppNavigationDrawer = ({ label={t`Advanced:`} /> ), - logo: '', } : { - logo: currentWorkspace?.logo ?? '', title: currentWorkspace?.displayName ?? '', children: , footer: , @@ -54,7 +52,6 @@ export const AppNavigationDrawer = ({ return ( diff --git a/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInAppNavigationDrawerMock.tsx b/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInAppNavigationDrawerMock.tsx index eec4e1606..b6eee1156 100644 --- a/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInAppNavigationDrawerMock.tsx +++ b/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInAppNavigationDrawerMock.tsx @@ -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 = ({ {children} diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader.tsx b/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader.tsx index a811dc390..e4679db7c 100644 --- a/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader.tsx +++ b/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader.tsx @@ -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; diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useDropdown.ts b/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useDropdown.ts index 3fb8e5352..52141418f 100644 --- a/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useDropdown.ts +++ b/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useDropdown.ts @@ -80,12 +80,12 @@ export const useDropdown = (dropdownId?: string) => { } }, [ - dropdownId, isDropdownOpen, + setIsDropdownOpen, + setActiveDropdownFocusIdAndMemorizePrevious, + dropdownId, scopeId, setHotkeyScopeAndMemorizePreviousScope, - setActiveDropdownFocusIdAndMemorizePrevious, - setIsDropdownOpen, ], ); diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/MultiWorkspaceDropdownButton.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/MultiWorkspaceDropdownButton.tsx new file mode 100644 index 000000000..9146e50ba --- /dev/null +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/MultiWorkspaceDropdownButton.tsx @@ -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 ( + + } + dropdownComponents={} + onClose={() => { + setMultiWorkspaceDropdown('default'); + }} + /> + ); +}; diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/MultiWorkspaceDropdownClickableComponent.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/MultiWorkspaceDropdownClickableComponent.tsx new file mode 100644 index 000000000..10ed1cf61 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/MultiWorkspaceDropdownClickableComponent.tsx @@ -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 ( + + + + {currentWorkspace?.displayName ?? ''} + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/MultiWorkspaceDropdownDefaultComponents.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/MultiWorkspaceDropdownDefaultComponents.tsx new file mode 100644 index 000000000..3f2aad279 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/MultiWorkspaceDropdownDefaultComponents.tsx @@ -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 ( + <> + + } + DropdownOnEndIcon={ + + } + dropdownId={'multi-workspace-dropdown-context-menu'} + dropdownHotkeyScope={{ scope: SelectHotkeyScope.Select }} + dropdownComponents={ + + + + } + /> + } + > + {currentWorkspace?.displayName} + + + {workspaces + .filter(({ id }) => id !== currentWorkspace?.id) + .slice(0, 3) + .map((workspace) => ( + { + event?.preventDefault(); + handleChange(workspace); + }} + > + + } + selected={false} + /> + + ))} + {workspaces.length > 4 && ( + setMultiWorkspaceDropdownState('workspaces-list')} + hasSubMenu={true} + /> + )} + + {workspaces.length > 1 && } + + id === colorScheme)?.icon} + text={ + <> + {t`Theme `} + {` ยท ${colorScheme}`} + + } + hasSubMenu={true} + onClick={() => setMultiWorkspaceDropdownState('themes')} + /> + + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/MultiWorkspaceDropdownThemesComponents.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/MultiWorkspaceDropdownThemesComponents.tsx new file mode 100644 index 000000000..e587da030 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/MultiWorkspaceDropdownThemesComponents.tsx @@ -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 ( + + setMultiWorkspaceDropdownState('default')} + > + {t`Theme`} + + {colorSchemeList.map((theme) => ( + setColorScheme(theme.id)} + RightIcon={theme.id === colorScheme ? IconCheck : undefined} + /> + ))} + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/MultiWorkspaceDropdownWorkspacesListComponents.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/MultiWorkspaceDropdownWorkspacesListComponents.tsx new file mode 100644 index 000000000..354b2d7ec --- /dev/null +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/MultiWorkspaceDropdownWorkspacesListComponents.tsx @@ -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 ( + + setMultiWorkspaceDropdownState('default')} + > + {t`Other workspaces`} + + { + setSearchValue(event.target.value); + }} + /> + + {workspaces + .filter( + (workspace) => + workspace.id !== currentWorkspace?.id && + workspace.displayName + ?.toLowerCase() + .includes(searchValue.toLowerCase()), + ) + .map((workspace) => ( + { + event?.preventDefault(); + handleChange(workspace); + }} + > + + } + selected={currentWorkspace?.id === workspace.id} + /> + + ))} + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/MultiWorkspacesDropdownStyles.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/MultiWorkspacesDropdownStyles.tsx new file mode 100644 index 000000000..f5266cb92 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/MultiWorkspacesDropdownStyles.tsx @@ -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; +`; diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdownButton.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdownButton.tsx deleted file mode 100644 index 87b443626..000000000 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdownButton.tsx +++ /dev/null @@ -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 ( - - - - {currentWorkspace?.displayName ?? ''} - - - - - - } - dropdownComponents={ - - {workspaces.map((workspace) => ( - { - event?.preventDefault(); - handleChange(workspace); - }} - > - - } - selected={currentWorkspace?.id === workspace.id} - /> - - ))} - - } - /> - ); -}; diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawer.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawer.tsx index 811c3efd0..cf8b1bf7b 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawer.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawer.tsx @@ -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 && ) : ( - + )} {children} diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerHeader.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerHeader.tsx index eed449b0f..aed5fe5e3 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerHeader.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerHeader.tsx @@ -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 ( - {isMultiWorkspace ? ( - - ) : ( - - - - {name} - - - )} + {!isMobile && isNavigationDrawerExpanded && ( ({ + key: 'multiWorkspaceDropdownState', + default: 'default', +}); diff --git a/packages/twenty-front/src/modules/ui/theme/hooks/useColorScheme.ts b/packages/twenty-front/src/modules/ui/theme/hooks/useColorScheme.ts index e3b46c7ad..659d6c78e 100644 --- a/packages/twenty-front/src/modules/ui/theme/hooks/useColorScheme.ts +++ b/packages/twenty-front/src/modules/ui/theme/hooks/useColorScheme.ts @@ -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, }; }; diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts index c6f46d451..dfaf64bdf 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts @@ -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: {}, diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts index bbe5e7039..66cb8219e 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts @@ -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 { + 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, diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts index c52429c5e..ee9c3e47b 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts @@ -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 = diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts index a911bfbda..e19826884 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts @@ -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 & diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts index 9e99ff0de..a306b9fc6 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts @@ -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); - - 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( diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts index 696db918d..c5d3237b7 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts @@ -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 }) diff --git a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts index 5e6f95bf6..95e6af620 100644 --- a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts +++ b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts @@ -281,9 +281,13 @@ export { IconUpload, IconUser, IconUserCircle, + IconSwitchHorizontal, IconUserCog, IconUserPin, IconUserPlus, + IconSunMoon, + IconMoon, + IconSun, IconUsers, IconVariable, IconVariablePlus,