feat: redirect to Plan Required page if subscription status is not active (#2981)

* feat: redirect to Plan Required page if subscription status is not active

Closes #2934

* feat: navigate to Plan Required in PageChangeEffect

* feat: add Twenty logo to Plan Required modal

* test: add Storybook story

* Fix lint

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Thaïs
2023-12-14 12:39:22 +01:00
committed by GitHub
parent 8916dee352
commit a10f353a4c
37 changed files with 285 additions and 110 deletions

View File

@ -8,6 +8,7 @@
"start:clean": "yarn start --force", "start:clean": "yarn start --force",
"build": "tsc && vite build && yarn build:inject-runtime-env", "build": "tsc && vite build && yarn build:inject-runtime-env",
"build:inject-runtime-env": "sh ./scripts/inject-runtime-env.sh", "build:inject-runtime-env": "sh ./scripts/inject-runtime-env.sh",
"tsc": "tsc --watch",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"fmt:fix": "prettier --cache --write \"src/**/*.ts\" \"src/**/*.tsx\"", "fmt:fix": "prettier --cache --write \"src/**/*.ts\" \"src/**/*.tsx\"",

View File

@ -10,6 +10,7 @@ import { CommandMenuEffect } from '~/effect-components/CommandMenuEffect';
import { GotoHotkeysEffect } from '~/effect-components/GotoHotkeysEffect'; import { GotoHotkeysEffect } from '~/effect-components/GotoHotkeysEffect';
import { CreateProfile } from '~/pages/auth/CreateProfile'; import { CreateProfile } from '~/pages/auth/CreateProfile';
import { CreateWorkspace } from '~/pages/auth/CreateWorkspace'; import { CreateWorkspace } from '~/pages/auth/CreateWorkspace';
import { PlanRequired } from '~/pages/auth/PlanRequired';
import { SignInUp } from '~/pages/auth/SignInUp'; import { SignInUp } from '~/pages/auth/SignInUp';
import { VerifyEffect } from '~/pages/auth/VerifyEffect'; import { VerifyEffect } from '~/pages/auth/VerifyEffect';
import { ImpersonateEffect } from '~/pages/impersonate/ImpersonateEffect'; import { ImpersonateEffect } from '~/pages/impersonate/ImpersonateEffect';
@ -50,6 +51,7 @@ export const App = () => {
<Route path={AppPath.Invite} element={<SignInUp />} /> <Route path={AppPath.Invite} element={<SignInUp />} />
<Route path={AppPath.CreateWorkspace} element={<CreateWorkspace />} /> <Route path={AppPath.CreateWorkspace} element={<CreateWorkspace />} />
<Route path={AppPath.CreateProfile} element={<CreateProfile />} /> <Route path={AppPath.CreateProfile} element={<CreateProfile />} />
<Route path={AppPath.PlanRequired} element={<PlanRequired />} />
<Route path="/" element={<Navigate to="/objects/companies" />} /> <Route path="/" element={<Navigate to="/objects/companies" />} />
<Route path={AppPath.TasksPage} element={<Tasks />} /> <Route path={AppPath.TasksPage} element={<Tasks />} />
<Route path={AppPath.Impersonate} element={<ImpersonateEffect />} /> <Route path={AppPath.Impersonate} element={<ImpersonateEffect />} />

View File

@ -57,12 +57,10 @@ export const PageChangeEffect = () => {
isMatchingLocation(AppPath.Verify); isMatchingLocation(AppPath.Verify);
const isMatchingOnboardingRoute = const isMatchingOnboardingRoute =
isMatchingLocation(AppPath.SignUp) || isMachinOngoingUserCreationRoute ||
isMatchingLocation(AppPath.SignIn) ||
isMatchingLocation(AppPath.Invite) ||
isMatchingLocation(AppPath.Verify) ||
isMatchingLocation(AppPath.CreateWorkspace) || isMatchingLocation(AppPath.CreateWorkspace) ||
isMatchingLocation(AppPath.CreateProfile); isMatchingLocation(AppPath.CreateProfile) ||
isMatchingLocation(AppPath.PlanRequired);
const navigateToSignUp = () => { const navigateToSignUp = () => {
enqueueSnackBar('workspace does not exist', { enqueueSnackBar('workspace does not exist', {
@ -76,6 +74,14 @@ export const PageChangeEffect = () => {
!isMachinOngoingUserCreationRoute !isMachinOngoingUserCreationRoute
) { ) {
navigate(AppPath.SignIn); navigate(AppPath.SignIn);
} else if (
onboardingStatus &&
[OnboardingStatus.Canceled, OnboardingStatus.Incomplete].includes(
onboardingStatus,
) &&
!isMatchingLocation(AppPath.PlanRequired)
) {
navigate(AppPath.PlanRequired);
} else if ( } else if (
onboardingStatus === OnboardingStatus.OngoingWorkspaceCreation && onboardingStatus === OnboardingStatus.OngoingWorkspaceCreation &&
!isMatchingLocation(AppPath.CreateWorkspace) !isMatchingLocation(AppPath.CreateWorkspace)
@ -170,6 +176,10 @@ export const PageChangeEffect = () => {
setHotkeyScope(PageHotkeyScope.CreateWokspace); setHotkeyScope(PageHotkeyScope.CreateWokspace);
break; break;
} }
case isMatchingLocation(AppPath.PlanRequired): {
setHotkeyScope(PageHotkeyScope.PlanRequired);
break;
}
case isMatchingLocation(SettingsPath.ProfilePage, AppBasePath.Settings): { case isMatchingLocation(SettingsPath.ProfilePage, AppBasePath.Settings): {
setHotkeyScope(PageHotkeyScope.ProfilePage, { setHotkeyScope(PageHotkeyScope.ProfilePage, {
goto: true, goto: true,

View File

@ -41,6 +41,12 @@ export type AuthTokenPair = {
refreshToken: AuthToken; refreshToken: AuthToken;
}; };
export type Billing = {
__typename?: 'Billing';
billingUrl: Scalars['String']['output'];
isBillingEnabled: Scalars['Boolean']['output'];
};
export type BooleanFieldComparison = { export type BooleanFieldComparison = {
is?: InputMaybe<Scalars['Boolean']['input']>; is?: InputMaybe<Scalars['Boolean']['input']>;
isNot?: InputMaybe<Scalars['Boolean']['input']>; isNot?: InputMaybe<Scalars['Boolean']['input']>;
@ -484,6 +490,7 @@ export type Workspace = {
id: Scalars['ID']['output']; id: Scalars['ID']['output'];
inviteHash?: Maybe<Scalars['String']['output']>; inviteHash?: Maybe<Scalars['String']['output']>;
logo?: Maybe<Scalars['String']['output']>; logo?: Maybe<Scalars['String']['output']>;
subscriptionStatus: Scalars['String']['output'];
updatedAt: Scalars['DateTime']['output']; updatedAt: Scalars['DateTime']['output'];
}; };

View File

@ -54,6 +54,12 @@ export type AuthTokens = {
tokens: AuthTokenPair; tokens: AuthTokenPair;
}; };
export type Billing = {
__typename?: 'Billing';
billingUrl: Scalars['String'];
isBillingEnabled: Scalars['Boolean'];
};
export type BooleanFieldComparison = { export type BooleanFieldComparison = {
is?: InputMaybe<Scalars['Boolean']>; is?: InputMaybe<Scalars['Boolean']>;
isNot?: InputMaybe<Scalars['Boolean']>; isNot?: InputMaybe<Scalars['Boolean']>;
@ -62,6 +68,7 @@ export type BooleanFieldComparison = {
export type ClientConfig = { export type ClientConfig = {
__typename?: 'ClientConfig'; __typename?: 'ClientConfig';
authProviders: AuthProviders; authProviders: AuthProviders;
billing: Billing;
debugMode: Scalars['Boolean']; debugMode: Scalars['Boolean'];
signInPrefilled: Scalars['Boolean']; signInPrefilled: Scalars['Boolean'];
support: Support; support: Support;
@ -507,6 +514,7 @@ export type Workspace = {
id: Scalars['ID']; id: Scalars['ID'];
inviteHash?: Maybe<Scalars['String']>; inviteHash?: Maybe<Scalars['String']>;
logo?: Maybe<Scalars['String']>; logo?: Maybe<Scalars['String']>;
subscriptionStatus: Scalars['String'];
updatedAt: Scalars['DateTime']; updatedAt: Scalars['DateTime'];
}; };
@ -660,7 +668,7 @@ export type ImpersonateMutationVariables = Exact<{
}>; }>;
export type ImpersonateMutation = { __typename?: 'Mutation', impersonate: { __typename?: 'Verify', user: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, workspaceMember: { __typename?: 'UserWorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'UserWorkspaceMemberName', firstName: string, lastName: string } }, defaultWorkspace: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: string, key: string, value: boolean, workspaceId: string }> | null } }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; export type ImpersonateMutation = { __typename?: 'Mutation', impersonate: { __typename?: 'Verify', user: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, workspaceMember: { __typename?: 'UserWorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'UserWorkspaceMemberName', firstName: string, lastName: string } }, defaultWorkspace: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, subscriptionStatus: string, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: string, key: string, value: boolean, workspaceId: string }> | null } }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } };
export type RenewTokenMutationVariables = Exact<{ export type RenewTokenMutationVariables = Exact<{
refreshToken: Scalars['String']; refreshToken: Scalars['String'];
@ -683,7 +691,7 @@ export type VerifyMutationVariables = Exact<{
}>; }>;
export type VerifyMutation = { __typename?: 'Mutation', verify: { __typename?: 'Verify', user: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, workspaceMember: { __typename?: 'UserWorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'UserWorkspaceMemberName', firstName: string, lastName: string } }, defaultWorkspace: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: string, key: string, value: boolean, workspaceId: string }> | null } }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; export type VerifyMutation = { __typename?: 'Mutation', verify: { __typename?: 'Verify', user: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, workspaceMember: { __typename?: 'UserWorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'UserWorkspaceMemberName', firstName: string, lastName: string } }, defaultWorkspace: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, subscriptionStatus: string, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: string, key: string, value: boolean, workspaceId: string }> | null } }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } };
export type CheckUserExistsQueryVariables = Exact<{ export type CheckUserExistsQueryVariables = Exact<{
email: Scalars['String']; email: Scalars['String'];
@ -695,7 +703,7 @@ export type CheckUserExistsQuery = { __typename?: 'Query', checkUserExists: { __
export type GetClientConfigQueryVariables = Exact<{ [key: string]: never; }>; export type GetClientConfigQueryVariables = Exact<{ [key: string]: never; }>;
export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, debugMode: boolean, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean }, telemetry: { __typename?: 'Telemetry', enabled: boolean, anonymizationEnabled: boolean }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null } } }; export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, debugMode: boolean, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean }, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl: string }, telemetry: { __typename?: 'Telemetry', enabled: boolean, anonymizationEnabled: boolean }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null } } };
export type UploadFileMutationVariables = Exact<{ export type UploadFileMutationVariables = Exact<{
file: Scalars['Upload']; file: Scalars['Upload'];
@ -713,7 +721,7 @@ export type UploadImageMutationVariables = Exact<{
export type UploadImageMutation = { __typename?: 'Mutation', uploadImage: string }; export type UploadImageMutation = { __typename?: 'Mutation', uploadImage: string };
export type UserQueryFragmentFragment = { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, workspaceMember: { __typename?: 'UserWorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'UserWorkspaceMemberName', firstName: string, lastName: string } }, defaultWorkspace: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: string, key: string, value: boolean, workspaceId: string }> | null } }; export type UserQueryFragmentFragment = { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, workspaceMember: { __typename?: 'UserWorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'UserWorkspaceMemberName', firstName: string, lastName: string } }, defaultWorkspace: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, subscriptionStatus: string, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: string, key: string, value: boolean, workspaceId: string }> | null } };
export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }>; export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }>;
@ -730,7 +738,7 @@ export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProf
export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>; export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>;
export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, workspaceMember: { __typename?: 'UserWorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'UserWorkspaceMemberName', firstName: string, lastName: string } }, defaultWorkspace: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: string, key: string, value: boolean, workspaceId: string }> | null } } }; export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, workspaceMember: { __typename?: 'UserWorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'UserWorkspaceMemberName', firstName: string, lastName: string } }, defaultWorkspace: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, subscriptionStatus: string, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: string, key: string, value: boolean, workspaceId: string }> | null } } };
export type DeleteCurrentWorkspaceMutationVariables = Exact<{ [key: string]: never; }>; export type DeleteCurrentWorkspaceMutationVariables = Exact<{ [key: string]: never; }>;
@ -742,7 +750,7 @@ export type UpdateWorkspaceMutationVariables = Exact<{
}>; }>;
export type UpdateWorkspaceMutation = { __typename?: 'Mutation', updateWorkspace: { __typename?: 'Workspace', id: string, domainName?: string | null, displayName?: string | null, logo?: string | null, allowImpersonation: boolean } }; export type UpdateWorkspaceMutation = { __typename?: 'Mutation', updateWorkspace: { __typename?: 'Workspace', id: string, domainName?: string | null, displayName?: string | null, logo?: string | null, allowImpersonation: boolean, subscriptionStatus: string } };
export type UploadWorkspaceLogoMutationVariables = Exact<{ export type UploadWorkspaceLogoMutationVariables = Exact<{
file: Scalars['Upload']; file: Scalars['Upload'];
@ -799,6 +807,7 @@ export const UserQueryFragmentFragmentDoc = gql`
domainName domainName
inviteHash inviteHash
allowImpersonation allowImpersonation
subscriptionStatus
featureFlags { featureFlags {
id id
key key
@ -1142,6 +1151,10 @@ export const GetClientConfigDocument = gql`
google google
password password
} }
billing {
isBillingEnabled
billingUrl
}
signInPrefilled signInPrefilled
debugMode debugMode
telemetry { telemetry {
@ -1312,39 +1325,10 @@ export type UploadProfilePictureMutationOptions = Apollo.BaseMutationOptions<Upl
export const GetCurrentUserDocument = gql` export const GetCurrentUserDocument = gql`
query GetCurrentUser { query GetCurrentUser {
currentUser { currentUser {
id ...UserQueryFragment
firstName
lastName
email
canImpersonate
supportUserHash
workspaceMember {
id
name {
firstName
lastName
}
colorScheme
avatarUrl
locale
}
defaultWorkspace {
id
displayName
logo
domainName
inviteHash
allowImpersonation
featureFlags {
id
key
value
workspaceId
}
}
} }
} }
`; ${UserQueryFragmentFragmentDoc}`;
/** /**
* __useGetCurrentUserQuery__ * __useGetCurrentUserQuery__
@ -1412,6 +1396,7 @@ export const UpdateWorkspaceDocument = gql`
displayName displayName
logo logo
allowImpersonation allowImpersonation
subscriptionStatus
} }
} }
`; `;

View File

@ -1,14 +1,8 @@
import { JSX, ReactNode } from 'react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
type SubTitleProps = {
children: ReactNode;
};
const StyledSubTitle = styled.div` const StyledSubTitle = styled.div`
color: ${({ theme }) => theme.font.color.secondary}; color: ${({ theme }) => theme.font.color.secondary};
text-align: center;
`; `;
export const SubTitle = ({ children }: SubTitleProps): JSX.Element => ( export { StyledSubTitle as SubTitle };
<StyledSubTitle>{children}</StyledSubTitle>
);

View File

@ -2,6 +2,7 @@ import { useRecoilValue } from 'recoil';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { billingState } from '@/client-config/states/billingState';
import { useIsLogged } from '../hooks/useIsLogged'; import { useIsLogged } from '../hooks/useIsLogged';
import { import {
@ -10,13 +11,15 @@ import {
} from '../utils/getOnboardingStatus'; } from '../utils/getOnboardingStatus';
export const useOnboardingStatus = (): OnboardingStatus | undefined => { export const useOnboardingStatus = (): OnboardingStatus | undefined => {
const billing = useRecoilValue(billingState);
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const currentWorkspace = useRecoilValue(currentWorkspaceState); const currentWorkspace = useRecoilValue(currentWorkspaceState);
const isLoggedIn = useIsLogged(); const isLoggedIn = useIsLogged();
return getOnboardingStatus( return getOnboardingStatus({
isLoggedIn, isLoggedIn,
currentWorkspaceMember, currentWorkspaceMember,
currentWorkspace, currentWorkspace,
); isBillingEnabled: billing?.isBillingEnabled,
});
}; };

View File

@ -6,6 +6,7 @@ import { useRecoilState, useRecoilValue } from 'recoil';
import { z } from 'zod'; import { z } from 'zod';
import { authProvidersState } from '@/client-config/states/authProvidersState'; import { authProvidersState } from '@/client-config/states/authProvidersState';
import { billingState } from '@/client-config/states/billingState';
import { isSignInPrefilledState } from '@/client-config/states/isSignInPrefilledState'; import { isSignInPrefilledState } from '@/client-config/states/isSignInPrefilledState';
import { AppPath } from '@/types/AppPath'; import { AppPath } from '@/types/AppPath';
import { PageHotkeyScope } from '@/types/PageHotkeyScope'; import { PageHotkeyScope } from '@/types/PageHotkeyScope';
@ -45,8 +46,11 @@ export const useSignInUp = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { enqueueSnackBar } = useSnackBar(); const { enqueueSnackBar } = useSnackBar();
const isMatchingLocation = useIsMatchingLocation(); const isMatchingLocation = useIsMatchingLocation();
const [authProviders] = useRecoilState(authProvidersState); const [authProviders] = useRecoilState(authProvidersState);
const isSignInPrefilled = useRecoilValue(isSignInPrefilledState); const isSignInPrefilled = useRecoilValue(isSignInPrefilledState);
const billing = useRecoilValue(billingState);
const workspaceInviteHash = useParams().workspaceInviteHash; const workspaceInviteHash = useParams().workspaceInviteHash;
const [signInUpStep, setSignInUpStep] = useState<SignInUpStep>( const [signInUpStep, setSignInUpStep] = useState<SignInUpStep>(
SignInUpStep.Init, SignInUpStep.Init,
@ -119,27 +123,33 @@ export const useSignInUp = () => {
if (!data.email || !data.password) { if (!data.email || !data.password) {
throw new Error('Email and password are required'); throw new Error('Email and password are required');
} }
let currentWorkspace;
if (signInUpMode === SignInUpMode.SignIn) { const { workspace: currentWorkspace } =
const { workspace } = await signInWithCredentials( signInUpMode === SignInUpMode.SignIn
data.email.toLowerCase(), ? await signInWithCredentials(
data.password, data.email.toLowerCase(),
); data.password,
currentWorkspace = workspace; )
} else { : await signUpWithCredentials(
const { workspace } = await signUpWithCredentials( data.email.toLowerCase(),
data.email.toLowerCase(), data.password,
data.password, workspaceInviteHash,
workspaceInviteHash, );
);
currentWorkspace = workspace; if (
billing?.isBillingEnabled &&
currentWorkspace.subscriptionStatus !== 'active'
) {
navigate('/plan-required');
return;
} }
if (currentWorkspace?.displayName) {
if (currentWorkspace.displayName) {
navigate('/'); navigate('/');
} else { return;
navigate('/create/workspace');
} }
navigate('/create/workspace');
} catch (err: any) { } catch (err: any) {
enqueueSnackBar(err?.message, { enqueueSnackBar(err?.message, {
variant: 'error', variant: 'error',
@ -151,6 +161,7 @@ export const useSignInUp = () => {
signInWithCredentials, signInWithCredentials,
signUpWithCredentials, signUpWithCredentials,
workspaceInviteHash, workspaceInviteHash,
billing?.isBillingEnabled,
navigate, navigate,
enqueueSnackBar, enqueueSnackBar,
], ],

View File

@ -10,6 +10,7 @@ export type CurrentWorkspace = Pick<
| 'displayName' | 'displayName'
| 'allowImpersonation' | 'allowImpersonation'
| 'featureFlags' | 'featureFlags'
| 'subscriptionStatus'
>; >;
export const currentWorkspaceState = atom<CurrentWorkspace | null>({ export const currentWorkspaceState = atom<CurrentWorkspace | null>({

View File

@ -2,17 +2,25 @@ import { CurrentWorkspace } from '@/auth/states/currentWorkspaceState';
import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember'; import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
export enum OnboardingStatus { export enum OnboardingStatus {
Incomplete = 'incomplete',
Canceled = 'canceled',
OngoingUserCreation = 'ongoing_user_creation', OngoingUserCreation = 'ongoing_user_creation',
OngoingWorkspaceCreation = 'ongoing_workspace_creation', OngoingWorkspaceCreation = 'ongoing_workspace_creation',
OngoingProfileCreation = 'ongoing_profile_creation', OngoingProfileCreation = 'ongoing_profile_creation',
Completed = 'completed', Completed = 'completed',
} }
export const getOnboardingStatus = ( export const getOnboardingStatus = ({
isLoggedIn: boolean, isLoggedIn,
currentWorkspaceMember: WorkspaceMember | null, currentWorkspaceMember,
currentWorkspace: CurrentWorkspace | null, currentWorkspace,
) => { isBillingEnabled,
}: {
isLoggedIn: boolean;
currentWorkspaceMember: WorkspaceMember | null;
currentWorkspace: CurrentWorkspace | null;
isBillingEnabled?: boolean;
}) => {
if (!isLoggedIn) { if (!isLoggedIn) {
return OnboardingStatus.OngoingUserCreation; return OnboardingStatus.OngoingUserCreation;
} }
@ -22,6 +30,17 @@ export const getOnboardingStatus = (
return undefined; return undefined;
} }
if (
isBillingEnabled &&
currentWorkspace?.subscriptionStatus === 'incomplete'
) {
return OnboardingStatus.Incomplete;
}
if (isBillingEnabled && currentWorkspace?.subscriptionStatus === 'canceled') {
return OnboardingStatus.Canceled;
}
if (!currentWorkspace?.displayName) { if (!currentWorkspace?.displayName) {
return OnboardingStatus.OngoingWorkspaceCreation; return OnboardingStatus.OngoingWorkspaceCreation;
} }

View File

@ -2,6 +2,7 @@ import { useEffect } from 'react';
import { useSetRecoilState } from 'recoil'; import { useSetRecoilState } from 'recoil';
import { authProvidersState } from '@/client-config/states/authProvidersState'; import { authProvidersState } from '@/client-config/states/authProvidersState';
import { billingState } from '@/client-config/states/billingState';
import { isDebugModeState } from '@/client-config/states/isDebugModeState'; import { isDebugModeState } from '@/client-config/states/isDebugModeState';
import { isSignInPrefilledState } from '@/client-config/states/isSignInPrefilledState'; import { isSignInPrefilledState } from '@/client-config/states/isSignInPrefilledState';
import { supportChatState } from '@/client-config/states/supportChatState'; import { supportChatState } from '@/client-config/states/supportChatState';
@ -16,6 +17,7 @@ export const ClientConfigProvider: React.FC<React.PropsWithChildren> = ({
const setIsSignInPrefilled = useSetRecoilState(isSignInPrefilledState); const setIsSignInPrefilled = useSetRecoilState(isSignInPrefilledState);
const setBilling = useSetRecoilState(billingState);
const setTelemetry = useSetRecoilState(telemetryState); const setTelemetry = useSetRecoilState(telemetryState);
const setSupportChat = useSetRecoilState(supportChatState); const setSupportChat = useSetRecoilState(supportChatState);
@ -31,6 +33,7 @@ export const ClientConfigProvider: React.FC<React.PropsWithChildren> = ({
setIsDebugMode(data?.clientConfig.debugMode); setIsDebugMode(data?.clientConfig.debugMode);
setIsSignInPrefilled(data?.clientConfig.signInPrefilled); setIsSignInPrefilled(data?.clientConfig.signInPrefilled);
setBilling(data?.clientConfig.billing);
setTelemetry(data?.clientConfig.telemetry); setTelemetry(data?.clientConfig.telemetry);
setSupportChat(data?.clientConfig.support); setSupportChat(data?.clientConfig.support);
} }
@ -41,6 +44,7 @@ export const ClientConfigProvider: React.FC<React.PropsWithChildren> = ({
setIsSignInPrefilled, setIsSignInPrefilled,
setTelemetry, setTelemetry,
setSupportChat, setSupportChat,
setBilling,
]); ]);
return loading ? <></> : <>{children}</>; return loading ? <></> : <>{children}</>;

View File

@ -7,6 +7,10 @@ export const GET_CLIENT_CONFIG = gql`
google google
password password
} }
billing {
isBillingEnabled
billingUrl
}
signInPrefilled signInPrefilled
debugMode debugMode
telemetry { telemetry {

View File

@ -0,0 +1,8 @@
import { atom } from 'recoil';
import { Billing } from '~/generated/graphql';
export const billingState = atom<Billing | null>({
key: 'billingState',
default: null,
});

View File

@ -2,7 +2,7 @@ import { useNavigate } from 'react-router-dom';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { Account } from '@/accounts/types/account'; import { Account } from '@/accounts/types/Account';
import { SettingsAccountsRowDropdownMenu } from '@/settings/accounts/components/SettingsAccountsRowDropdownMenu'; import { SettingsAccountsRowDropdownMenu } from '@/settings/accounts/components/SettingsAccountsRowDropdownMenu';
import { IconAt, IconPlus } from '@/ui/display/icon'; import { IconAt, IconPlus } from '@/ui/display/icon';
import { IconGoogle } from '@/ui/display/icon/components/IconGoogle'; import { IconGoogle } from '@/ui/display/icon/components/IconGoogle';

View File

@ -1,7 +1,7 @@
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { Account } from '@/accounts/types/account'; import { Account } from '@/accounts/types/Account';
import { IconChevronRight } from '@/ui/display/icon'; import { IconChevronRight } from '@/ui/display/icon';
import { IconGmail } from '@/ui/display/icon/components/IconGmail'; import { IconGmail } from '@/ui/display/icon/components/IconGmail';
import { Status } from '@/ui/display/status/components/Status'; import { Status } from '@/ui/display/status/components/Status';

View File

@ -2,7 +2,7 @@ import { ReactNode } from 'react';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { Account } from '@/accounts/types/account'; import { Account } from '@/accounts/types/Account';
import { IconComponent } from '@/ui/display/icon/types/IconComponent'; import { IconComponent } from '@/ui/display/icon/types/IconComponent';
import { CardContent } from '@/ui/layout/card/components/CardContent'; import { CardContent } from '@/ui/layout/card/components/CardContent';

View File

@ -1,6 +1,6 @@
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Account } from '@/accounts/types/account'; import { Account } from '@/accounts/types/Account';
import { IconDotsVertical, IconMail, IconTrash } from '@/ui/display/icon'; import { IconDotsVertical, IconMail, IconTrash } from '@/ui/display/icon';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton'; import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';

View File

@ -8,6 +8,7 @@ export enum AppPath {
// Onboarding // Onboarding
CreateWorkspace = '/create/workspace', CreateWorkspace = '/create/workspace',
CreateProfile = '/create/profile', CreateProfile = '/create/profile',
PlanRequired = '/plan-required',
// Onboarded // Onboarded
Index = '/', Index = '/',

View File

@ -3,6 +3,7 @@ export enum PageHotkeyScope {
CreateWokspace = 'create-workspace', CreateWokspace = 'create-workspace',
SignInUp = 'sign-in-up', SignInUp = 'sign-in-up',
CreateProfile = 'create-profile', CreateProfile = 'create-profile',
PlanRequired = 'plan-required',
ShowPage = 'show-page', ShowPage = 'show-page',
PersonShowPage = 'person-show-page', PersonShowPage = 'person-show-page',
CompanyShowPage = 'company-show-page', CompanyShowPage = 'company-show-page',

View File

@ -19,7 +19,7 @@ const StyledLeftContentWithCheckboxContainer = styled.div`
type MenuItemMultiSelectAvatarProps = { type MenuItemMultiSelectAvatarProps = {
avatar?: ReactNode; avatar?: ReactNode;
selected: boolean; selected: boolean;
isKeySelected: boolean; isKeySelected?: boolean;
text: string; text: string;
className?: string; className?: string;
onSelectChange?: (selected: boolean) => void; onSelectChange?: (selected: boolean) => void;

View File

@ -25,6 +25,7 @@ export const USER_QUERY_FRAGMENT = gql`
domainName domainName
inviteHash inviteHash
allowImpersonation allowImpersonation
subscriptionStatus
featureFlags { featureFlags {
id id
key key

View File

@ -3,36 +3,7 @@ import { gql } from '@apollo/client';
export const GET_CURRENT_USER = gql` export const GET_CURRENT_USER = gql`
query GetCurrentUser { query GetCurrentUser {
currentUser { currentUser {
id ...UserQueryFragment
firstName
lastName
email
canImpersonate
supportUserHash
workspaceMember {
id
name {
firstName
lastName
}
colorScheme
avatarUrl
locale
}
defaultWorkspace {
id
displayName
logo
domainName
inviteHash
allowImpersonation
featureFlags {
id
key
value
workspaceId
}
}
} }
} }
`; `;

View File

@ -8,6 +8,7 @@ export const UPDATE_WORKSPACE = gql`
displayName displayName
logo logo
allowImpersonation allowImpersonation
subscriptionStatus
} }
} }
`; `;

View File

@ -77,6 +77,8 @@ export const CreateWorkspace = () => {
setCurrentWorkspace({ setCurrentWorkspace({
id: result.data?.updateWorkspace?.id ?? '', id: result.data?.updateWorkspace?.id ?? '',
displayName: data.name, displayName: data.name,
subscriptionStatus:
result.data?.updateWorkspace?.subscriptionStatus ?? 'incomplete',
allowImpersonation: allowImpersonation:
result.data?.updateWorkspace?.allowImpersonation ?? false, result.data?.updateWorkspace?.allowImpersonation ?? false,
}); });

View File

@ -0,0 +1,50 @@
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { Logo } from '@/auth/components/Logo';
import { SubTitle } from '@/auth/components/SubTitle';
import { Title } from '@/auth/components/Title';
import { useOnboardingStatus } from '@/auth/hooks/useOnboardingStatus';
import { OnboardingStatus } from '@/auth/utils/getOnboardingStatus';
import { billingState } from '@/client-config/states/billingState';
import { PageHotkeyScope } from '@/types/PageHotkeyScope';
import { MainButton } from '@/ui/input/button/components/MainButton';
import { AnimatedEaseIn } from '@/ui/utilities/animation/components/AnimatedEaseIn';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
const StyledButtonContainer = styled.div`
margin-top: ${({ theme }) => theme.spacing(8)};
width: 200px;
`;
export const PlanRequired = () => {
const onboardingStatus = useOnboardingStatus();
const billing = useRecoilValue(billingState);
const handleButtonClick = () => {
billing?.billingUrl && window.location.replace(billing.billingUrl);
};
useScopedHotkeys('enter', handleButtonClick, PageHotkeyScope.PlanRequired, [
handleButtonClick,
]);
if (onboardingStatus === OnboardingStatus.Completed) {
return null;
}
return (
<>
<AnimatedEaseIn>
<Logo />
</AnimatedEaseIn>
<Title>Plan required</Title>
<SubTitle>
Please select a subscription plan before proceeding to sign in.
</SubTitle>
<StyledButtonContainer>
<MainButton title="Get started" onClick={handleButtonClick} fullWidth />
</StyledButtonContainer>
</>
);
};

View File

@ -0,0 +1,36 @@
import { Meta, StoryObj } from '@storybook/react';
import { within } from '@storybook/test';
import { AppPath } from '@/types/AppPath';
import {
PageDecorator,
PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { PlanRequired } from '../PlanRequired';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Auth/PlanRequired',
component: PlanRequired,
decorators: [PageDecorator],
args: { routePath: AppPath.PlanRequired },
parameters: {
msw: graphqlMocks,
cookie: {
tokenPair: '{}',
},
},
};
export default meta;
export type Story = StoryObj<typeof PlanRequired>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByRole('button', { name: 'Get started' });
},
};

View File

@ -1,4 +1,4 @@
import { Account } from '@/accounts/types/account'; import { Account } from '@/accounts/types/Account';
export const mockedAccounts: Account[] = [ export const mockedAccounts: Account[] = [
{ {

View File

@ -19,6 +19,8 @@ SIGN_IN_PREFILLED=true
# FRONT_AUTH_CALLBACK_URL=http://localhost:3001/verify # FRONT_AUTH_CALLBACK_URL=http://localhost:3001/verify
# AUTH_GOOGLE_ENABLED=false # AUTH_GOOGLE_ENABLED=false
# MESSAGING_PROVIDER_GMAIL_ENABLED=false # MESSAGING_PROVIDER_GMAIL_ENABLED=false
# IS_BILLING_ENABLED=false
# BILLING_PLAN_REQUIRED_LINK=https://twenty.com/stripe-redirection
# AUTH_GOOGLE_CLIENT_ID=replace_me_with_google_client_id # AUTH_GOOGLE_CLIENT_ID=replace_me_with_google_client_id
# AUTH_GOOGLE_CLIENT_SECRET=replace_me_with_google_client_secret # AUTH_GOOGLE_CLIENT_SECRET=replace_me_with_google_client_secret
# AUTH_GOOGLE_CALLBACK_URL=http://localhost:3000/auth/google/redirect # AUTH_GOOGLE_CALLBACK_URL=http://localhost:3000/auth/google/redirect

View File

@ -116,6 +116,7 @@ export class AuthService {
displayName: '', displayName: '',
domainName: '', domainName: '',
inviteHash: v4(), inviteHash: v4(),
subscriptionStatus: 'incomplete',
}); });
workspace = await this.workspaceRepository.save(workspaceToCreate); workspace = await this.workspaceRepository.save(workspaceToCreate);

View File

@ -21,6 +21,15 @@ class Telemetry {
anonymizationEnabled: boolean; anonymizationEnabled: boolean;
} }
@ObjectType()
class Billing {
@Field(() => Boolean)
isBillingEnabled: boolean;
@Field(() => String)
billingUrl: string;
}
@ObjectType() @ObjectType()
class Support { class Support {
@Field(() => String) @Field(() => String)
@ -38,6 +47,9 @@ export class ClientConfig {
@Field(() => Telemetry, { nullable: false }) @Field(() => Telemetry, { nullable: false })
telemetry: Telemetry; telemetry: Telemetry;
@Field(() => Billing, { nullable: false })
billing: Billing;
@Field(() => Boolean) @Field(() => Boolean)
signInPrefilled: boolean; signInPrefilled: boolean;

View File

@ -21,6 +21,10 @@ export class ClientConfigResolver {
anonymizationEnabled: anonymizationEnabled:
this.environmentService.isTelemetryAnonymizationEnabled(), this.environmentService.isTelemetryAnonymizationEnabled(),
}, },
billing: {
isBillingEnabled: this.environmentService.isBillingEnabled(),
billingUrl: this.environmentService.getBillingUrl(),
},
signInPrefilled: this.environmentService.isSignInPrefilled(), signInPrefilled: this.environmentService.isSignInPrefilled(),
debugMode: this.environmentService.isDebugMode(), debugMode: this.environmentService.isDebugMode(),
support: { support: {

View File

@ -58,4 +58,8 @@ export class Workspace {
@OneToMany(() => FeatureFlagEntity, (featureFlag) => featureFlag.workspace) @OneToMany(() => FeatureFlagEntity, (featureFlag) => featureFlag.workspace)
featureFlags: FeatureFlagEntity[]; featureFlags: FeatureFlagEntity[];
@Field()
@Column({ default: 'incomplete' })
subscriptionStatus: 'incomplete' | 'active' | 'canceled';
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddSubscriptionStatusOnWorkspace1702479005171
implements MigrationInterface
{
name = 'AddSubscriptionStatusOnWorkspace1702479005171';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."workspace" ADD "subscriptionStatus" character varying NOT NULL DEFAULT 'incomplete'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."workspace" DROP COLUMN "subscriptionStatus"`,
);
}
}

View File

@ -22,6 +22,14 @@ export class EnvironmentService {
return this.configService.get<boolean>('SIGN_IN_PREFILLED') ?? false; return this.configService.get<boolean>('SIGN_IN_PREFILLED') ?? false;
} }
isBillingEnabled() {
return this.configService.get<boolean>('IS_BILLING_ENABLED') ?? false;
}
getBillingUrl() {
return this.configService.get<string>('BILLING_PLAN_REQUIRED_LINK') ?? '';
}
isTelemetryEnabled(): boolean { isTelemetryEnabled(): boolean {
return this.configService.get<boolean>('TELEMETRY_ENABLED') ?? true; return this.configService.get<boolean>('TELEMETRY_ENABLED') ?? true;
} }

View File

@ -38,6 +38,15 @@ export class EnvironmentVariables {
@IsBoolean() @IsBoolean()
SIGN_IN_PREFILLED?: boolean; SIGN_IN_PREFILLED?: boolean;
@CastToBoolean()
@IsOptional()
@IsBoolean()
IS_BILLING_ENABLED?: boolean;
@IsOptional()
@IsString()
BILLING_URL?: string;
@CastToBoolean() @CastToBoolean()
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()