feat(invitation): Improve invitation flow - Milestone 2 (#6804)

From PR: #6626 
Resolves #6763 
Resolves #6055 
Resolves #6782

## GTK
I retain the 'Invite by link' feature to prevent any breaking changes.
We could make the invitation by link optional through an admin setting,
allowing users to rely solely on personal invitations.

## Todo
- [x] Add an expiration date to an invitation
- [x] Allow to renew an invitation to postpone the expiration date
- [x] Refresh the UI
- [x] Add the new personal token in the link sent to new user
- [x] Display an error if a user tries to use an expired invitation
- [x] Display an error if a user uses another mail than the one in the
invitation

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Antoine Moreaux
2024-09-18 23:27:31 +02:00
committed by GitHub
parent ad18c44f25
commit 89c97993e3
81 changed files with 1726 additions and 363 deletions

3
.gitignore vendored
View File

@@ -27,4 +27,5 @@ storybook-static
*.tsbuildinfo
.eslintcache
.nyc_output
test-results/
test-results/

View File

@@ -24,9 +24,9 @@ const jestConfig: JestConfigWithTsJest = {
extensionsToTreatAsEsm: ['.ts', '.tsx'],
coverageThreshold: {
global: {
statements: 62,
lines: 61,
functions: 52,
statements: 60,
lines: 60,
functions: 50,
},
},
collectCoverageFrom: ['<rootDir>/src/**/*.ts'],

View File

@@ -340,6 +340,7 @@ export type Mutation = {
activateWorkflowVersion: Scalars['Boolean'];
activateWorkspace: Workspace;
addUserToWorkspace: User;
addUserToWorkspaceByInviteToken: User;
authorizeApp: AuthorizeApp;
challenge: LoginToken;
checkoutSession: SessionEntity;
@@ -352,6 +353,7 @@ export type Mutation = {
deleteOneObject: Object;
deleteOneServerlessFunction: ServerlessFunction;
deleteUser: User;
deleteWorkspaceInvitation: Scalars['String'];
disablePostgresProxy: PostgresCredentials;
emailPasswordResetLink: EmailPasswordResetLink;
enablePostgresProxy: PostgresCredentials;
@@ -363,8 +365,9 @@ export type Mutation = {
impersonate: Verify;
publishServerlessFunction: ServerlessFunction;
renewToken: AuthTokens;
resendWorkspaceInvitation: SendInvitationsOutput;
runWorkflowVersion: WorkflowRun;
sendInviteLink: SendInviteLink;
sendInvitations: SendInvitationsOutput;
signUp: LoginToken;
skipSyncEmailOnboardingStep: OnboardingStepSuccess;
track: Analytics;
@@ -396,6 +399,11 @@ export type MutationAddUserToWorkspaceArgs = {
};
export type MutationAddUserToWorkspaceByInviteTokenArgs = {
inviteToken: Scalars['String'];
};
export type MutationAuthorizeAppArgs = {
clientId: Scalars['String'];
codeChallenge?: InputMaybe<Scalars['String']>;
@@ -442,6 +450,11 @@ export type MutationDeleteOneServerlessFunctionArgs = {
};
export type MutationDeleteWorkspaceInvitationArgs = {
appTokenId: Scalars['String'];
};
export type MutationEmailPasswordResetLinkArgs = {
email: Scalars['String'];
};
@@ -485,12 +498,17 @@ export type MutationRenewTokenArgs = {
};
export type MutationResendWorkspaceInvitationArgs = {
appTokenId: Scalars['String'];
};
export type MutationRunWorkflowVersionArgs = {
input: RunWorkflowVersionInput;
};
export type MutationSendInviteLinkArgs = {
export type MutationSendInvitationsArgs = {
emails: Array<Scalars['String']>;
};
@@ -500,6 +518,7 @@ export type MutationSignUpArgs = {
email: Scalars['String'];
password: Scalars['String'];
workspaceInviteHash?: InputMaybe<Scalars['String']>;
workspacePersonalInviteToken?: InputMaybe<Scalars['String']>;
};
@@ -636,6 +655,7 @@ export type Query = {
currentUser: User;
currentWorkspace: Workspace;
findWorkspaceFromInviteHash: Workspace;
findWorkspaceInvitations: Array<WorkspaceInvitation>;
getAISQLQuery: AisqlQueryResult;
getAvailablePackages: Scalars['JSON'];
getPostgresCredentials?: Maybe<PostgresCredentials>;
@@ -790,8 +810,10 @@ export type RunWorkflowVersionInput = {
workflowVersionId: Scalars['String'];
};
export type SendInviteLink = {
__typename?: 'SendInviteLink';
export type SendInvitationsOutput = {
__typename?: 'SendInvitationsOutput';
errors: Array<Scalars['String']>;
result: Array<WorkspaceInvitation>;
/** Boolean that confirms query was dispatched */
success: Scalars['Boolean'];
};
@@ -1147,6 +1169,13 @@ export type WorkspaceEdge = {
node: Workspace;
};
export type WorkspaceInvitation = {
__typename?: 'WorkspaceInvitation';
email: Scalars['String'];
expiresAt: Scalars['DateTime'];
id: Scalars['UUID'];
};
export type WorkspaceInviteHashValid = {
__typename?: 'WorkspaceInviteHashValid';
isValid: Scalars['Boolean'];
@@ -1415,6 +1444,7 @@ export type SignUpMutationVariables = Exact<{
email: Scalars['String'];
password: Scalars['String'];
workspaceInviteHash?: InputMaybe<Scalars['String']>;
workspacePersonalInviteToken?: InputMaybe<Scalars['String']>;
captchaToken?: InputMaybe<Scalars['String']>;
}>;
@@ -1514,6 +1544,32 @@ export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>;
export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> } };
export type DeleteWorkspaceInvitationMutationVariables = Exact<{
appTokenId: Scalars['String'];
}>;
export type DeleteWorkspaceInvitationMutation = { __typename?: 'Mutation', deleteWorkspaceInvitation: string };
export type ResendWorkspaceInvitationMutationVariables = Exact<{
appTokenId: Scalars['String'];
}>;
export type ResendWorkspaceInvitationMutation = { __typename?: 'Mutation', resendWorkspaceInvitation: { __typename?: 'SendInvitationsOutput', success: boolean, errors: Array<string>, result: Array<{ __typename?: 'WorkspaceInvitation', id: any, email: string, expiresAt: string }> } };
export type SendInvitationsMutationVariables = Exact<{
emails: Array<Scalars['String']> | Scalars['String'];
}>;
export type SendInvitationsMutation = { __typename?: 'Mutation', sendInvitations: { __typename?: 'SendInvitationsOutput', success: boolean, errors: Array<string>, result: Array<{ __typename?: 'WorkspaceInvitation', id: any, email: string, expiresAt: string }> } };
export type GetWorkspaceInvitationsQueryVariables = Exact<{ [key: string]: never; }>;
export type GetWorkspaceInvitationsQuery = { __typename?: 'Query', findWorkspaceInvitations: Array<{ __typename?: 'WorkspaceInvitation', id: any, email: string, expiresAt: string }> };
export type WorkspaceMemberQueryFragmentFragment = { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } };
export type AddUserToWorkspaceMutationVariables = Exact<{
@@ -1523,6 +1579,13 @@ export type AddUserToWorkspaceMutationVariables = Exact<{
export type AddUserToWorkspaceMutation = { __typename?: 'Mutation', addUserToWorkspace: { __typename?: 'User', id: any } };
export type AddUserToWorkspaceByInviteTokenMutationVariables = Exact<{
inviteToken: Scalars['String'];
}>;
export type AddUserToWorkspaceByInviteTokenMutation = { __typename?: 'Mutation', addUserToWorkspaceByInviteToken: { __typename?: 'User', id: any } };
export type ActivateWorkspaceMutationVariables = Exact<{
input: ActivateWorkspaceInput;
}>;
@@ -1535,13 +1598,6 @@ export type DeleteCurrentWorkspaceMutationVariables = Exact<{ [key: string]: nev
export type DeleteCurrentWorkspaceMutation = { __typename?: 'Mutation', deleteCurrentWorkspace: { __typename?: 'Workspace', id: any } };
export type SendInviteLinkMutationVariables = Exact<{
emails: Array<Scalars['String']> | Scalars['String'];
}>;
export type SendInviteLinkMutation = { __typename?: 'Mutation', sendInviteLink: { __typename?: 'SendInviteLink', success: boolean } };
export type UpdateWorkspaceMutationVariables = Exact<{
input: UpdateWorkspaceInput;
}>;
@@ -2262,11 +2318,12 @@ export type RenewTokenMutationHookResult = ReturnType<typeof useRenewTokenMutati
export type RenewTokenMutationResult = Apollo.MutationResult<RenewTokenMutation>;
export type RenewTokenMutationOptions = Apollo.BaseMutationOptions<RenewTokenMutation, RenewTokenMutationVariables>;
export const SignUpDocument = gql`
mutation SignUp($email: String!, $password: String!, $workspaceInviteHash: String, $captchaToken: String) {
mutation SignUp($email: String!, $password: String!, $workspaceInviteHash: String, $workspacePersonalInviteToken: String = null, $captchaToken: String) {
signUp(
email: $email
password: $password
workspaceInviteHash: $workspaceInviteHash
workspacePersonalInviteToken: $workspacePersonalInviteToken
captchaToken: $captchaToken
) {
loginToken {
@@ -2293,6 +2350,7 @@ export type SignUpMutationFn = Apollo.MutationFunction<SignUpMutation, SignUpMut
* email: // value for 'email'
* password: // value for 'password'
* workspaceInviteHash: // value for 'workspaceInviteHash'
* workspacePersonalInviteToken: // value for 'workspacePersonalInviteToken'
* captchaToken: // value for 'captchaToken'
* },
* });
@@ -2828,6 +2886,155 @@ export function useGetCurrentUserLazyQuery(baseOptions?: Apollo.LazyQueryHookOpt
export type GetCurrentUserQueryHookResult = ReturnType<typeof useGetCurrentUserQuery>;
export type GetCurrentUserLazyQueryHookResult = ReturnType<typeof useGetCurrentUserLazyQuery>;
export type GetCurrentUserQueryResult = Apollo.QueryResult<GetCurrentUserQuery, GetCurrentUserQueryVariables>;
export const DeleteWorkspaceInvitationDocument = gql`
mutation DeleteWorkspaceInvitation($appTokenId: String!) {
deleteWorkspaceInvitation(appTokenId: $appTokenId)
}
`;
export type DeleteWorkspaceInvitationMutationFn = Apollo.MutationFunction<DeleteWorkspaceInvitationMutation, DeleteWorkspaceInvitationMutationVariables>;
/**
* __useDeleteWorkspaceInvitationMutation__
*
* To run a mutation, you first call `useDeleteWorkspaceInvitationMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useDeleteWorkspaceInvitationMutation` 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 [deleteWorkspaceInvitationMutation, { data, loading, error }] = useDeleteWorkspaceInvitationMutation({
* variables: {
* appTokenId: // value for 'appTokenId'
* },
* });
*/
export function useDeleteWorkspaceInvitationMutation(baseOptions?: Apollo.MutationHookOptions<DeleteWorkspaceInvitationMutation, DeleteWorkspaceInvitationMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<DeleteWorkspaceInvitationMutation, DeleteWorkspaceInvitationMutationVariables>(DeleteWorkspaceInvitationDocument, options);
}
export type DeleteWorkspaceInvitationMutationHookResult = ReturnType<typeof useDeleteWorkspaceInvitationMutation>;
export type DeleteWorkspaceInvitationMutationResult = Apollo.MutationResult<DeleteWorkspaceInvitationMutation>;
export type DeleteWorkspaceInvitationMutationOptions = Apollo.BaseMutationOptions<DeleteWorkspaceInvitationMutation, DeleteWorkspaceInvitationMutationVariables>;
export const ResendWorkspaceInvitationDocument = gql`
mutation ResendWorkspaceInvitation($appTokenId: String!) {
resendWorkspaceInvitation(appTokenId: $appTokenId) {
success
errors
result {
... on WorkspaceInvitation {
id
email
expiresAt
}
}
}
}
`;
export type ResendWorkspaceInvitationMutationFn = Apollo.MutationFunction<ResendWorkspaceInvitationMutation, ResendWorkspaceInvitationMutationVariables>;
/**
* __useResendWorkspaceInvitationMutation__
*
* To run a mutation, you first call `useResendWorkspaceInvitationMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useResendWorkspaceInvitationMutation` 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 [resendWorkspaceInvitationMutation, { data, loading, error }] = useResendWorkspaceInvitationMutation({
* variables: {
* appTokenId: // value for 'appTokenId'
* },
* });
*/
export function useResendWorkspaceInvitationMutation(baseOptions?: Apollo.MutationHookOptions<ResendWorkspaceInvitationMutation, ResendWorkspaceInvitationMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<ResendWorkspaceInvitationMutation, ResendWorkspaceInvitationMutationVariables>(ResendWorkspaceInvitationDocument, options);
}
export type ResendWorkspaceInvitationMutationHookResult = ReturnType<typeof useResendWorkspaceInvitationMutation>;
export type ResendWorkspaceInvitationMutationResult = Apollo.MutationResult<ResendWorkspaceInvitationMutation>;
export type ResendWorkspaceInvitationMutationOptions = Apollo.BaseMutationOptions<ResendWorkspaceInvitationMutation, ResendWorkspaceInvitationMutationVariables>;
export const SendInvitationsDocument = gql`
mutation SendInvitations($emails: [String!]!) {
sendInvitations(emails: $emails) {
success
errors
result {
... on WorkspaceInvitation {
id
email
expiresAt
}
}
}
}
`;
export type SendInvitationsMutationFn = Apollo.MutationFunction<SendInvitationsMutation, SendInvitationsMutationVariables>;
/**
* __useSendInvitationsMutation__
*
* To run a mutation, you first call `useSendInvitationsMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useSendInvitationsMutation` 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 [sendInvitationsMutation, { data, loading, error }] = useSendInvitationsMutation({
* variables: {
* emails: // value for 'emails'
* },
* });
*/
export function useSendInvitationsMutation(baseOptions?: Apollo.MutationHookOptions<SendInvitationsMutation, SendInvitationsMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<SendInvitationsMutation, SendInvitationsMutationVariables>(SendInvitationsDocument, options);
}
export type SendInvitationsMutationHookResult = ReturnType<typeof useSendInvitationsMutation>;
export type SendInvitationsMutationResult = Apollo.MutationResult<SendInvitationsMutation>;
export type SendInvitationsMutationOptions = Apollo.BaseMutationOptions<SendInvitationsMutation, SendInvitationsMutationVariables>;
export const GetWorkspaceInvitationsDocument = gql`
query GetWorkspaceInvitations {
findWorkspaceInvitations {
id
email
expiresAt
}
}
`;
/**
* __useGetWorkspaceInvitationsQuery__
*
* To run a query within a React component, call `useGetWorkspaceInvitationsQuery` and pass it any options that fit your needs.
* When your component renders, `useGetWorkspaceInvitationsQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useGetWorkspaceInvitationsQuery({
* variables: {
* },
* });
*/
export function useGetWorkspaceInvitationsQuery(baseOptions?: Apollo.QueryHookOptions<GetWorkspaceInvitationsQuery, GetWorkspaceInvitationsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetWorkspaceInvitationsQuery, GetWorkspaceInvitationsQueryVariables>(GetWorkspaceInvitationsDocument, options);
}
export function useGetWorkspaceInvitationsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetWorkspaceInvitationsQuery, GetWorkspaceInvitationsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetWorkspaceInvitationsQuery, GetWorkspaceInvitationsQueryVariables>(GetWorkspaceInvitationsDocument, options);
}
export type GetWorkspaceInvitationsQueryHookResult = ReturnType<typeof useGetWorkspaceInvitationsQuery>;
export type GetWorkspaceInvitationsLazyQueryHookResult = ReturnType<typeof useGetWorkspaceInvitationsLazyQuery>;
export type GetWorkspaceInvitationsQueryResult = Apollo.QueryResult<GetWorkspaceInvitationsQuery, GetWorkspaceInvitationsQueryVariables>;
export const AddUserToWorkspaceDocument = gql`
mutation AddUserToWorkspace($inviteHash: String!) {
addUserToWorkspace(inviteHash: $inviteHash) {
@@ -2861,6 +3068,39 @@ export function useAddUserToWorkspaceMutation(baseOptions?: Apollo.MutationHookO
export type AddUserToWorkspaceMutationHookResult = ReturnType<typeof useAddUserToWorkspaceMutation>;
export type AddUserToWorkspaceMutationResult = Apollo.MutationResult<AddUserToWorkspaceMutation>;
export type AddUserToWorkspaceMutationOptions = Apollo.BaseMutationOptions<AddUserToWorkspaceMutation, AddUserToWorkspaceMutationVariables>;
export const AddUserToWorkspaceByInviteTokenDocument = gql`
mutation AddUserToWorkspaceByInviteToken($inviteToken: String!) {
addUserToWorkspaceByInviteToken(inviteToken: $inviteToken) {
id
}
}
`;
export type AddUserToWorkspaceByInviteTokenMutationFn = Apollo.MutationFunction<AddUserToWorkspaceByInviteTokenMutation, AddUserToWorkspaceByInviteTokenMutationVariables>;
/**
* __useAddUserToWorkspaceByInviteTokenMutation__
*
* To run a mutation, you first call `useAddUserToWorkspaceByInviteTokenMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useAddUserToWorkspaceByInviteTokenMutation` 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 [addUserToWorkspaceByInviteTokenMutation, { data, loading, error }] = useAddUserToWorkspaceByInviteTokenMutation({
* variables: {
* inviteToken: // value for 'inviteToken'
* },
* });
*/
export function useAddUserToWorkspaceByInviteTokenMutation(baseOptions?: Apollo.MutationHookOptions<AddUserToWorkspaceByInviteTokenMutation, AddUserToWorkspaceByInviteTokenMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<AddUserToWorkspaceByInviteTokenMutation, AddUserToWorkspaceByInviteTokenMutationVariables>(AddUserToWorkspaceByInviteTokenDocument, options);
}
export type AddUserToWorkspaceByInviteTokenMutationHookResult = ReturnType<typeof useAddUserToWorkspaceByInviteTokenMutation>;
export type AddUserToWorkspaceByInviteTokenMutationResult = Apollo.MutationResult<AddUserToWorkspaceByInviteTokenMutation>;
export type AddUserToWorkspaceByInviteTokenMutationOptions = Apollo.BaseMutationOptions<AddUserToWorkspaceByInviteTokenMutation, AddUserToWorkspaceByInviteTokenMutationVariables>;
export const ActivateWorkspaceDocument = gql`
mutation ActivateWorkspace($input: ActivateWorkspaceInput!) {
activateWorkspace(data: $input) {
@@ -2926,39 +3166,6 @@ export function useDeleteCurrentWorkspaceMutation(baseOptions?: Apollo.MutationH
export type DeleteCurrentWorkspaceMutationHookResult = ReturnType<typeof useDeleteCurrentWorkspaceMutation>;
export type DeleteCurrentWorkspaceMutationResult = Apollo.MutationResult<DeleteCurrentWorkspaceMutation>;
export type DeleteCurrentWorkspaceMutationOptions = Apollo.BaseMutationOptions<DeleteCurrentWorkspaceMutation, DeleteCurrentWorkspaceMutationVariables>;
export const SendInviteLinkDocument = gql`
mutation SendInviteLink($emails: [String!]!) {
sendInviteLink(emails: $emails) {
success
}
}
`;
export type SendInviteLinkMutationFn = Apollo.MutationFunction<SendInviteLinkMutation, SendInviteLinkMutationVariables>;
/**
* __useSendInviteLinkMutation__
*
* To run a mutation, you first call `useSendInviteLinkMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useSendInviteLinkMutation` 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 [sendInviteLinkMutation, { data, loading, error }] = useSendInviteLinkMutation({
* variables: {
* emails: // value for 'emails'
* },
* });
*/
export function useSendInviteLinkMutation(baseOptions?: Apollo.MutationHookOptions<SendInviteLinkMutation, SendInviteLinkMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<SendInviteLinkMutation, SendInviteLinkMutationVariables>(SendInviteLinkDocument, options);
}
export type SendInviteLinkMutationHookResult = ReturnType<typeof useSendInviteLinkMutation>;
export type SendInviteLinkMutationResult = Apollo.MutationResult<SendInviteLinkMutation>;
export type SendInviteLinkMutationOptions = Apollo.BaseMutationOptions<SendInviteLinkMutation, SendInviteLinkMutationVariables>;
export const UpdateWorkspaceDocument = gql`
mutation UpdateWorkspace($input: UpdateWorkspaceInput!) {
updateWorkspace(data: $input) {

View File

@@ -5,12 +5,14 @@ export const SIGN_UP = gql`
$email: String!
$password: String!
$workspaceInviteHash: String
$workspacePersonalInviteToken: String = null
$captchaToken: String
) {
signUp(
email: $email
password: $password
workspaceInviteHash: $workspaceInviteHash
workspacePersonalInviteToken: $workspacePersonalInviteToken
captchaToken: $captchaToken
) {
loginToken {

View File

@@ -264,6 +264,7 @@ export const useAuth = () => {
email: string,
password: string,
workspaceInviteHash?: string,
workspacePersonalInviteToken?: string,
captchaToken?: string,
) => {
setIsVerifyPendingState(true);
@@ -273,6 +274,7 @@ export const useAuth = () => {
email,
password,
workspaceInviteHash,
workspacePersonalInviteToken,
captchaToken,
},
});
@@ -296,21 +298,43 @@ export const useAuth = () => {
[setIsVerifyPendingState, signUp, handleVerify],
);
const handleGoogleLogin = useCallback((workspaceInviteHash?: string) => {
const buildRedirectUrl = (
path: string,
params: {
workspacePersonalInviteToken?: string;
workspaceInviteHash?: string;
},
) => {
const authServerUrl = REACT_APP_SERVER_BASE_URL;
window.location.href =
`${authServerUrl}/auth/google/${
workspaceInviteHash ? '?inviteHash=' + workspaceInviteHash : ''
}` || '';
}, []);
const url = new URL(`${authServerUrl}${path}`);
if (isDefined(params.workspaceInviteHash)) {
url.searchParams.set('inviteHash', params.workspaceInviteHash);
}
if (isDefined(params.workspacePersonalInviteToken)) {
url.searchParams.set('inviteToken', params.workspacePersonalInviteToken);
}
return url.toString();
};
const handleMicrosoftLogin = useCallback((workspaceInviteHash?: string) => {
const authServerUrl = REACT_APP_SERVER_BASE_URL;
window.location.href =
`${authServerUrl}/auth/microsoft/${
workspaceInviteHash ? '?inviteHash=' + workspaceInviteHash : ''
}` || '';
}, []);
const handleGoogleLogin = useCallback(
(params: {
workspacePersonalInviteToken?: string;
workspaceInviteHash?: string;
}) => {
window.location.href = buildRedirectUrl('/auth/google', params);
},
[],
);
const handleMicrosoftLogin = useCallback(
(params: {
workspacePersonalInviteToken?: string;
workspaceInviteHash?: string;
}) => {
window.location.href = buildRedirectUrl('/auth/microsoft', params);
},
[],
);
return {
challenge: handleChallenge,

View File

@@ -1,6 +1,6 @@
import { useCallback, useState } from 'react';
import { SubmitHandler, UseFormReturn } from 'react-hook-form';
import { useParams } from 'react-router-dom';
import { useParams, useSearchParams } from 'react-router-dom';
import { Form } from '@/auth/sign-in-up/hooks/useSignInUpForm';
import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken';
@@ -29,6 +29,9 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
const isMatchingLocation = useIsMatchingLocation();
const workspaceInviteHash = useParams().workspaceInviteHash;
const [searchParams] = useSearchParams();
const workspacePersonalInviteToken =
searchParams.get('inviteToken') ?? undefined;
const [isInviteMode] = useState(() => isMatchingLocation(AppPath.Invite));
@@ -112,6 +115,7 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
data.email.toLowerCase().trim(),
data.password,
workspaceInviteHash,
workspacePersonalInviteToken,
token,
);
} catch (err: any) {
@@ -128,6 +132,7 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
signInWithCredentials,
signUpWithCredentials,
workspaceInviteHash,
workspacePersonalInviteToken,
enqueueSnackBar,
requestFreshCaptchaToken,
],

View File

@@ -1,9 +1,15 @@
import { useParams } from 'react-router-dom';
import { useParams, useSearchParams } from 'react-router-dom';
import { useAuth } from '@/auth/hooks/useAuth';
export const useSignInWithGoogle = () => {
const workspaceInviteHash = useParams().workspaceInviteHash;
const [searchParams] = useSearchParams();
const workspacePersonalInviteToken =
searchParams.get('inviteToken') ?? undefined;
const { signInWithGoogle } = useAuth();
return { signInWithGoogle: () => signInWithGoogle(workspaceInviteHash) };
return {
signInWithGoogle: () =>
signInWithGoogle({ workspaceInviteHash, workspacePersonalInviteToken }),
};
};

View File

@@ -1,11 +1,18 @@
import { useParams } from 'react-router-dom';
import { useParams, useSearchParams } from 'react-router-dom';
import { useAuth } from '@/auth/hooks/useAuth';
export const useSignInWithMicrosoft = () => {
const workspaceInviteHash = useParams().workspaceInviteHash;
const [searchParams] = useSearchParams();
const workspacePersonalInviteToken =
searchParams.get('inviteToken') ?? undefined;
const { signInWithMicrosoft } = useAuth();
return {
signInWithMicrosoft: () => signInWithMicrosoft(workspaceInviteHash),
signInWithMicrosoft: () =>
signInWithMicrosoft({
workspaceInviteHash,
workspacePersonalInviteToken,
}),
};
};

View File

@@ -7,6 +7,7 @@ import { AppPath } from '@/types/AppPath';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { isDefaultLayoutAuthModalVisibleState } from '@/ui/layout/states/isDefaultLayoutAuthModalVisibleState';
import { useGetWorkspaceFromInviteHashQuery } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';

View File

@@ -15,7 +15,7 @@ export const useObjectMetadataItem = ({
}: ObjectMetadataItemIdentifier) => {
const currentWorkspace = useRecoilValue(currentWorkspaceState);
// Todo: deprecate this logic as mocked objectMetadataItems are laod in ObjectMetadataItemsLoadEffect anyway
// Todo: deprecate this logic as mocked objectMetadataItems are load in ObjectMetadataItemsLoadEffect anyway
const mockObjectMetadataItems = getObjectMetadataItemsMock();
let objectMetadataItem = useRecoilValue(

View File

@@ -9,12 +9,13 @@ const StyledTableRow = styled('div', {
isSelected?: boolean;
onClick?: () => void;
to?: string;
gridAutoColumns?: string;
}>`
background-color: ${({ isSelected, theme }) =>
isSelected ? theme.accent.quaternary : 'transparent'};
border-radius: ${({ theme }) => theme.border.radius.sm};
display: grid;
grid-auto-columns: 1fr;
grid-auto-columns: ${({ gridAutoColumns }) => gridAutoColumns ?? '1fr'};
grid-auto-flow: column;
transition: background-color
${({ theme }) => theme.animation.duration.normal}s;
@@ -33,6 +34,7 @@ type TableRowProps = {
onClick?: () => void;
to?: string;
className?: string;
gridAutoColumns?: string;
};
export const TableRow = ({
@@ -41,10 +43,12 @@ export const TableRow = ({
to,
className,
children,
gridAutoColumns,
}: React.PropsWithChildren<TableRowProps>) => (
<StyledTableRow
isSelected={isSelected}
onClick={onClick}
gridAutoColumns={gridAutoColumns}
className={className}
to={to}
as={to ? Link : 'div'}

View File

@@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const DELETE_WORKSPACE_INVITATION = gql`
mutation DeleteWorkspaceInvitation($appTokenId: String!) {
deleteWorkspaceInvitation(appTokenId: $appTokenId)
}
`;

View File

@@ -0,0 +1,17 @@
import { gql } from '@apollo/client';
export const RESEND_WORKSPACE_INVITATION = gql`
mutation ResendWorkspaceInvitation($appTokenId: String!) {
resendWorkspaceInvitation(appTokenId: $appTokenId) {
success
errors
result {
... on WorkspaceInvitation {
id
email
expiresAt
}
}
}
}
`;

View File

@@ -0,0 +1,17 @@
import { gql } from '@apollo/client';
export const SEND_INVITATIONS = gql`
mutation SendInvitations($emails: [String!]!) {
sendInvitations(emails: $emails) {
success
errors
result {
... on WorkspaceInvitation {
id
email
expiresAt
}
}
}
}
`;

View File

@@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
export const GET_WORKSPACE_INVITATIONS = gql`
query GetWorkspaceInvitations {
findWorkspaceInvitations {
id
email
expiresAt
}
}
`;

View File

@@ -0,0 +1,26 @@
import { useSetRecoilState } from 'recoil';
import { useSendInvitationsMutation } from '~/generated/graphql';
import { SendInvitationsMutationVariables } from '../../../generated/graphql';
import { workspaceInvitationsState } from '../states/workspaceInvitationsStates';
export const useCreateWorkspaceInvitation = () => {
const [sendInvitationsMutation] = useSendInvitationsMutation();
const setWorkspaceInvitations = useSetRecoilState(workspaceInvitationsState);
const sendInvitation = async (emails: SendInvitationsMutationVariables) => {
return await sendInvitationsMutation({
variables: emails,
onCompleted: (data) => {
setWorkspaceInvitations((workspaceInvitations) => [
...workspaceInvitations,
...data.sendInvitations.result,
]);
},
});
};
return {
sendInvitation,
};
};

View File

@@ -0,0 +1,34 @@
import { useSetRecoilState } from 'recoil';
import {
DeleteWorkspaceInvitationMutationVariables,
useDeleteWorkspaceInvitationMutation,
} from '~/generated/graphql';
import { workspaceInvitationsState } from '../states/workspaceInvitationsStates';
export const useDeleteWorkspaceInvitation = () => {
const [deleteWorkspaceInvitationMutation] =
useDeleteWorkspaceInvitationMutation();
const setWorkspaceInvitations = useSetRecoilState(workspaceInvitationsState);
const deleteWorkspaceInvitation = async ({
appTokenId,
}: DeleteWorkspaceInvitationMutationVariables) => {
return await deleteWorkspaceInvitationMutation({
variables: {
appTokenId,
},
onCompleted: () => {
setWorkspaceInvitations((workspaceInvitations) =>
workspaceInvitations.filter(
(workspaceInvitation) => workspaceInvitation.id !== appTokenId,
),
);
},
});
};
return {
deleteWorkspaceInvitation,
};
};

View File

@@ -0,0 +1,35 @@
import { useSetRecoilState } from 'recoil';
import {
ResendWorkspaceInvitationMutationVariables,
useResendWorkspaceInvitationMutation,
} from '~/generated/graphql';
import { workspaceInvitationsState } from '../states/workspaceInvitationsStates';
export const useResendWorkspaceInvitation = () => {
const [resendWorkspaceInvitationMutation] =
useResendWorkspaceInvitationMutation();
const setWorkspaceInvitations = useSetRecoilState(workspaceInvitationsState);
const resendInvitation = async ({
appTokenId,
}: ResendWorkspaceInvitationMutationVariables) => {
return await resendWorkspaceInvitationMutation({
variables: {
appTokenId,
},
onCompleted: (data) => {
setWorkspaceInvitations((workspaceInvitations) => [
...data.resendWorkspaceInvitation.result,
...workspaceInvitations.filter(
(workspaceInvitation) => workspaceInvitation.id !== appTokenId,
),
]);
},
});
};
return {
resendInvitation,
};
};

View File

@@ -0,0 +1,9 @@
import { createState } from 'twenty-ui';
import { WorkspaceInvitation } from '@/workspace-member/types/WorkspaceMember';
export const workspaceInvitationsState = createState<
Omit<WorkspaceInvitation, '__typename'>[]
>({
key: 'workspaceInvitationsState',
defaultValue: [],
});

View File

@@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const ADD_USER_TO_WORKSPACE_BY_INVITE_TOKEN = gql`
mutation AddUserToWorkspaceByInviteToken($inviteToken: String!) {
addUserToWorkspaceByInviteToken(inviteToken: $inviteToken) {
id
}
}
`;

View File

@@ -24,3 +24,10 @@ export type WorkspaceMember = {
dateFormat?: WorkspaceMemberDateFormatEnum | null;
timeFormat?: WorkspaceMemberTimeFormatEnum | null;
};
export type WorkspaceInvitation = {
__typename: 'WorkspaceInvitation';
id: string;
email: string;
expiresAt: string;
};

View File

@@ -1,9 +1,9 @@
import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import styled from '@emotion/styled';
import { zodResolver } from '@hookform/resolvers/zod';
import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { Key } from 'ts-key-enum';
import { IconMail, IconSend } from 'twenty-ui';
import { IconSend } from 'twenty-ui';
import { z } from 'zod';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
@@ -11,12 +11,13 @@ import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Button } from '@/ui/input/button/components/Button';
import { TextInput } from '@/ui/input/components/TextInput';
import { sanitizeEmailList } from '@/workspace/utils/sanitizeEmailList';
import { useSendInviteLinkMutation } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
import { useCreateWorkspaceInvitation } from '../../workspace-invitation/hooks/useCreateWorkspaceInvitation';
const StyledContainer = styled.div`
display: flex;
flex-direction: row;
padding-bottom: ${({ theme }) => theme.spacing(3)};
`;
const StyledLinkContainer = styled.div`
@@ -69,7 +70,7 @@ type FormInput = {
export const WorkspaceInviteTeam = () => {
const { enqueueSnackBar } = useSnackBar();
const [sendInviteLink] = useSendInviteLinkMutation();
const { sendInvitation } = useCreateWorkspaceInvitation();
const { reset, handleSubmit, control, formState } = useForm<FormInput>({
mode: 'onSubmit',
@@ -79,16 +80,27 @@ export const WorkspaceInviteTeam = () => {
},
});
const submit = handleSubmit(async (data) => {
const emailsList = sanitizeEmailList(data.emails.split(','));
const result = await sendInviteLink({ variables: { emails: emailsList } });
if (isDefined(result.errors)) {
throw result.errors;
const submit = handleSubmit(async ({ emails }) => {
const emailsList = sanitizeEmailList(emails.split(','));
const { data } = await sendInvitation({ emails: emailsList });
if (isDefined(data) && data.sendInvitations.result.length > 0) {
enqueueSnackBar(
`${data.sendInvitations.result.length} invitations sent`,
{
variant: SnackBarVariant.Success,
duration: 2000,
},
);
return;
}
if (isDefined(data) && !data.sendInvitations.success) {
data.sendInvitations.errors.forEach((error) => {
enqueueSnackBar(error, {
variant: SnackBarVariant.Error,
duration: 5000,
});
});
}
enqueueSnackBar('Invite link sent to email addresses', {
variant: SnackBarVariant.Success,
duration: 2000,
});
});
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
@@ -116,7 +128,6 @@ export const WorkspaceInviteTeam = () => {
return (
<TextInput
placeholder="tim@apple.com, jony.ive@apple.dev"
LeftIcon={IconMail}
value={value}
onChange={onChange}
error={error?.message}

View File

@@ -1,57 +0,0 @@
import styled from '@emotion/styled';
import { Avatar, OverflowingTextWithTooltip } from 'twenty-ui';
import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
const StyledContainer = styled.div`
background: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.spacing(2)};
display: flex;
flex-direction: row;
margin-bottom: ${({ theme }) => theme.spacing(0)};
margin-top: ${({ theme }) => theme.spacing(4)};
padding: ${({ theme }) => theme.spacing(3)};
`;
const StyledContent = styled.div`
display: flex;
flex: 1;
flex-direction: column;
justify-content: center;
margin-left: ${({ theme }) => theme.spacing(3)};
overflow: auto;
`;
const StyledEmailText = styled.span`
color: ${({ theme }) => theme.font.color.tertiary};
`;
type WorkspaceMemberCardProps = {
workspaceMember: WorkspaceMember;
accessory?: React.ReactNode;
};
export const WorkspaceMemberCard = ({
workspaceMember,
accessory,
}: WorkspaceMemberCardProps) => (
<StyledContainer>
<Avatar
avatarUrl={workspaceMember.avatarUrl}
placeholderColorSeed={workspaceMember.id}
placeholder={workspaceMember.name.firstName || ''}
type="squared"
size="xl"
/>
<StyledContent>
<OverflowingTextWithTooltip
text={
workspaceMember.name.firstName + ' ' + workspaceMember.name.lastName
}
/>
<StyledEmailText>{workspaceMember.userEmail}</StyledEmailText>
</StyledContent>
{accessory}
</StyledContainer>
);

View File

@@ -1,9 +0,0 @@
import { gql } from '@apollo/client';
export const SEND_INVITE_LINK = gql`
mutation SendInviteLink($emails: [String!]!) {
sendInviteLink(emails: $emails) {
success
}
}
`;

View File

@@ -13,8 +13,12 @@ import { Loader } from '@/ui/feedback/loader/components/Loader';
import { MainButton } from '@/ui/input/button/components/MainButton';
import { useWorkspaceSwitching } from '@/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching';
import { AnimatedEaseIn } from '@/ui/utilities/animation/components/AnimatedEaseIn';
import { useAddUserToWorkspaceMutation } from '~/generated/graphql';
import {
useAddUserToWorkspaceMutation,
useAddUserToWorkspaceByInviteTokenMutation,
} from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
import { useSearchParams } from 'react-router-dom';
const StyledContentContainer = styled.div`
margin-bottom: ${({ theme }) => theme.spacing(8)};
@@ -24,26 +28,40 @@ const StyledContentContainer = styled.div`
export const Invite = () => {
const { workspace: workspaceFromInviteHash, workspaceInviteHash } =
useWorkspaceFromInviteHash();
const { form } = useSignInUpForm();
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const [addUserToWorkspace] = useAddUserToWorkspaceMutation();
const [addUserToWorkspaceByInviteToken] =
useAddUserToWorkspaceByInviteTokenMutation();
const { switchWorkspace } = useWorkspaceSwitching();
const [searchParams] = useSearchParams();
const workspaceInviteToken = searchParams.get('inviteToken');
const title = useMemo(() => {
return `Join ${workspaceFromInviteHash?.displayName ?? ''} team`;
}, [workspaceFromInviteHash?.displayName]);
const handleUserJoinWorkspace = async () => {
if (
!(isDefined(workspaceInviteHash) && isDefined(workspaceFromInviteHash))
if (isDefined(workspaceInviteToken) && isDefined(workspaceFromInviteHash)) {
await addUserToWorkspaceByInviteToken({
variables: {
inviteToken: workspaceInviteToken,
},
});
} else if (
isDefined(workspaceInviteHash) &&
isDefined(workspaceFromInviteHash)
) {
await addUserToWorkspace({
variables: {
inviteHash: workspaceInviteHash,
},
});
} else {
return;
}
await addUserToWorkspace({
variables: {
inviteHash: workspaceInviteHash,
},
});
await switchWorkspace(workspaceFromInviteHash.id);
};

View File

@@ -27,11 +27,9 @@ import { MainButton } from '@/ui/input/button/components/MainButton';
import { TextInputV2 } from '@/ui/input/components/TextInputV2';
import { AnimatedTranslation } from '@/ui/utilities/animation/components/AnimatedTranslation';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import {
OnboardingStatus,
useSendInviteLinkMutation,
} from '~/generated/graphql';
import { OnboardingStatus } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
import { useCreateWorkspaceInvitation } from '../../modules/workspace-invitation/hooks/useCreateWorkspaceInvitation';
const StyledAnimatedContainer = styled.div`
display: flex;
@@ -65,7 +63,8 @@ type FormInput = z.infer<typeof validationSchema>;
export const InviteTeam = () => {
const theme = useTheme();
const { enqueueSnackBar } = useSnackBar();
const [sendInviteLink] = useSendInviteLinkMutation();
const { sendInvitation } = useCreateWorkspaceInvitation();
const setNextOnboardingStatus = useSetNextOnboardingStatus();
const currentUser = useRecoilValue(currentUserState);
const currentWorkspace = useRecoilValue(currentWorkspaceState);
@@ -134,7 +133,7 @@ export const InviteTeam = () => {
.filter((email) => email.length > 0),
),
);
const result = await sendInviteLink({ variables: { emails } });
const result = await sendInvitation({ emails });
setNextOnboardingStatus();
@@ -148,7 +147,7 @@ export const InviteTeam = () => {
});
}
},
[enqueueSnackBar, sendInviteLink, setNextOnboardingStatus],
[enqueueSnackBar, sendInvitation, setNextOnboardingStatus],
);
useScopedHotkeys(

View File

@@ -1,7 +1,17 @@
import styled from '@emotion/styled';
import { useState } from 'react';
import { useRecoilValue } from 'recoil';
import { H2Title, IconTrash, IconUsers } from 'twenty-ui';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import {
H2Title,
IconTrash,
IconUsers,
IconReload,
IconMail,
StyledText,
Avatar,
} from 'twenty-ui';
import { isNonEmptyArray } from '@sniptt/guards';
import { useTheme } from '@emotion/react';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
@@ -18,7 +28,19 @@ import { Section } from '@/ui/layout/section/components/Section';
import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
import { WorkspaceInviteLink } from '@/workspace/components/WorkspaceInviteLink';
import { WorkspaceInviteTeam } from '@/workspace/components/WorkspaceInviteTeam';
import { WorkspaceMemberCard } from '@/workspace/components/WorkspaceMemberCard';
import { useGetWorkspaceInvitationsQuery } from '~/generated/graphql';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Table } from '@/ui/layout/table/components/Table';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { workspaceInvitationsState } from '../../modules/workspace-invitation/states/workspaceInvitationsStates';
import { TableRow } from '../../modules/ui/layout/table/components/TableRow';
import { TableCell } from '../../modules/ui/layout/table/components/TableCell';
import { Status } from '../../modules/ui/display/status/components/Status';
import { formatDistanceToNow } from 'date-fns';
import { useResendWorkspaceInvitation } from '../../modules/workspace-invitation/hooks/useResendWorkspaceInvitation';
import { isDefined } from '~/utils/isDefined';
import { useDeleteWorkspaceInvitation } from '../../modules/workspace-invitation/hooks/useDeleteWorkspaceInvitation';
const StyledButtonContainer = styled.div`
align-items: center;
@@ -27,7 +49,17 @@ const StyledButtonContainer = styled.div`
margin-left: ${({ theme }) => theme.spacing(3)};
`;
const StyledTable = styled(Table)`
margin-top: ${({ theme }) => theme.spacing(0.5)};
`;
const StyledTableHeaderRow = styled(Table)`
margin-bottom: ${({ theme }) => theme.spacing(1.5)};
`;
export const SettingsWorkspaceMembers = () => {
const { enqueueSnackBar } = useSnackBar();
const theme = useTheme();
const [isConfirmationModalOpen, setIsConfirmationModalOpen] = useState(false);
const [workspaceMemberToDelete, setWorkspaceMemberToDelete] = useState<
string | undefined
@@ -39,6 +71,10 @@ export const SettingsWorkspaceMembers = () => {
const { deleteOneRecord: deleteOneWorkspaceMember } = useDeleteOneRecord({
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
});
const { resendInvitation } = useResendWorkspaceInvitation();
const { deleteWorkspaceInvitation } = useDeleteWorkspaceInvitation();
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
@@ -47,6 +83,47 @@ export const SettingsWorkspaceMembers = () => {
setIsConfirmationModalOpen(false);
};
const workspaceInvitations = useRecoilValue(workspaceInvitationsState);
const setWorkspaceInvitations = useSetRecoilState(workspaceInvitationsState);
useGetWorkspaceInvitationsQuery({
onError: (error: Error) => {
enqueueSnackBar(error.message, {
variant: SnackBarVariant.Error,
});
},
onCompleted: (data) => {
setWorkspaceInvitations(data?.findWorkspaceInvitations ?? []);
},
});
const handleRemoveWorkspaceInvitation = async (appTokenId: string) => {
const result = await deleteWorkspaceInvitation({ appTokenId });
if (isDefined(result.errors)) {
enqueueSnackBar('Error deleting invitation', {
variant: SnackBarVariant.Error,
duration: 2000,
});
}
};
const handleResendWorkspaceInvitation = async (appTokenId: string) => {
const result = await resendInvitation({ appTokenId });
if (isDefined(result.errors)) {
enqueueSnackBar('Error resending invitation', {
variant: SnackBarVariant.Error,
duration: 2000,
});
}
};
const getExpiresAtText = (expiresAt: string) => {
const expiresAtDate = new Date(expiresAt);
return expiresAtDate < new Date()
? 'Expired'
: formatDistanceToNow(new Date(expiresAt));
};
return (
<SubMenuTopBarContainer
Icon={IconUsers}
@@ -60,18 +137,11 @@ export const SettingsWorkspaceMembers = () => {
]}
>
<SettingsPageContainer>
<Section>
<H2Title
title="Invite by email"
description="Send an invite email to your team"
/>
<WorkspaceInviteTeam />
</Section>
{currentWorkspace?.inviteHash && (
<Section>
<H2Title
title="Or send an invite link"
description="Copy and send an invite link directly"
title="Invite by link"
description="Share this link to invite users to join your workspace"
/>
<WorkspaceInviteLink
inviteLink={`${window.location.origin}/invite/${currentWorkspace?.inviteHash}`}
@@ -83,27 +153,125 @@ export const SettingsWorkspaceMembers = () => {
title="Members"
description="Manage the members of your space here"
/>
{workspaceMembers?.map((member) => (
<WorkspaceMemberCard
key={member.id}
workspaceMember={member as WorkspaceMember}
accessory={
currentWorkspaceMember?.id !== member.id && (
<StyledButtonContainer>
<IconButton
onClick={() => {
setIsConfirmationModalOpen(true);
setWorkspaceMemberToDelete(member.id);
}}
variant="tertiary"
size="medium"
Icon={IconTrash}
<Table>
<StyledTableHeaderRow>
<TableRow>
<TableHeader>Name</TableHeader>
<TableHeader>Email</TableHeader>
<TableHeader align={'right'}></TableHeader>
</TableRow>
</StyledTableHeaderRow>
{workspaceMembers?.map((workspaceMember) => (
<StyledTable key={workspaceMember.id}>
<TableRow>
<TableCell>
<StyledText
PrefixComponent={
<Avatar
avatarUrl={workspaceMember.avatarUrl}
placeholderColorSeed={workspaceMember.id}
placeholder={workspaceMember.name.firstName ?? ''}
type="rounded"
size="sm"
/>
}
text={
workspaceMember.name.firstName +
' ' +
workspaceMember.name.lastName
}
/>
</StyledButtonContainer>
)
}
/>
))}
</TableCell>
<TableCell>
<StyledText
text={workspaceMember.userEmail}
color={theme.font.color.secondary}
/>
</TableCell>
<TableCell align={'right'}>
{currentWorkspaceMember?.id !== workspaceMember.id && (
<StyledButtonContainer>
<IconButton
onClick={() => {
setIsConfirmationModalOpen(true);
setWorkspaceMemberToDelete(workspaceMember.id);
}}
variant="tertiary"
size="medium"
Icon={IconTrash}
/>
</StyledButtonContainer>
)}
</TableCell>
</TableRow>
</StyledTable>
))}
</Table>
</Section>
<Section>
<H2Title
title="Invite by email"
description="Send an invite email to your team"
/>
<WorkspaceInviteTeam />
{isNonEmptyArray(workspaceInvitations) && (
<Table>
<StyledTableHeaderRow>
<TableRow gridAutoColumns={`1fr 1fr ${theme.spacing(22)}`}>
<TableHeader>Email</TableHeader>
<TableHeader align={'right'}>Expires in</TableHeader>
<TableHeader></TableHeader>
</TableRow>
</StyledTableHeaderRow>
{workspaceInvitations?.map((workspaceInvitation) => (
<StyledTable key={workspaceInvitation.id}>
<TableRow gridAutoColumns={`1fr 1fr ${theme.spacing(22)}`}>
<TableCell>
<StyledText
PrefixComponent={
<IconMail
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
}
text={workspaceInvitation.email}
/>
</TableCell>
<TableCell align={'right'}>
<Status
color={'gray'}
text={getExpiresAtText(workspaceInvitation.expiresAt)}
/>
</TableCell>
<TableCell align={'right'}>
<StyledButtonContainer>
<IconButton
onClick={() => {
handleResendWorkspaceInvitation(
workspaceInvitation.id,
);
}}
variant="tertiary"
size="medium"
Icon={IconReload}
/>
<IconButton
onClick={() => {
handleRemoveWorkspaceInvitation(
workspaceInvitation.id,
);
}}
variant="tertiary"
size="medium"
Icon={IconTrash}
/>
</StyledButtonContainer>
</TableCell>
</TableRow>
</StyledTable>
))}
</Table>
)}
</Section>
</SettingsPageContainer>
<ConfirmationModal

View File

@@ -0,0 +1,51 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddInvitation1724056827317 implements MigrationInterface {
name = 'AddInvitation1724056827317';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE core."appToken" ALTER COLUMN "userId" DROP NOT NULL',
);
await queryRunner.query(
`ALTER TABLE core."appToken" ADD CONSTRAINT "userIdIsNullWhenTypeIsInvitation" CHECK ("appToken".type != 'INVITATION_TOKEN' OR "appToken"."userId" IS NULL)`,
);
await queryRunner.query(
`ALTER TABLE core."appToken" ADD CONSTRAINT "userIdNotNullWhenTypeIsNotInvitation" CHECK ("appToken".type = 'INVITATION_TOKEN' OR "appToken"."userId" NOTNULL)`,
);
await queryRunner.query('ALTER TABLE core."appToken" ADD "context" jsonb');
await queryRunner.query(
'CREATE UNIQUE INDEX apptoken_unique_invitation_by_user_workspace ON core."appToken" ("workspaceId", ("context" ->> \'email\')) WHERE type = \'INVITATION_TOKEN\';',
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX core.apptoken_unique_invitation_by_user_workspace;`,
);
await queryRunner.query(
'DELETE FROM "core"."appToken" WHERE "userId" IS NULL',
);
await queryRunner.query(
'ALTER TABLE core."appToken" DROP CONSTRAINT "userIdIsNullWhenTypeIsInvitation"',
);
await queryRunner.query(
'ALTER TABLE core."appToken" DROP CONSTRAINT "userIdNotNullWhenTypeIsNotInvitation"',
);
await queryRunner.query(
'ALTER TABLE core."appToken" DROP COLUMN "context"',
);
await queryRunner.query(
'ALTER TABLE core."appToken" ALTER COLUMN "userId" SET NOT NULL',
);
}
}

View File

@@ -14,7 +14,7 @@ import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';
import { useThrottler } from 'src/engine/api/graphql/graphql-config/hooks/use-throttler';
import { WorkspaceSchemaFactory } from 'src/engine/api/graphql/workspace-schema.factory';
import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { CoreEngineModule } from 'src/engine/core-modules/core-engine.module';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';

View File

@@ -35,11 +35,15 @@ export class GraphqlQueryFindOneResolverService {
): Promise<ObjectRecord | undefined> {
const { authContext, objectMetadataItem, info, objectMetadataCollection } =
options;
const repository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
const dataSource =
await this.twentyORMGlobalManager.getDataSourceForWorkspace(
authContext.workspace.id,
objectMetadataItem.nameSingular,
);
const repository = await dataSource.getRepository<ObjectRecord>(
objectMetadataItem.nameSingular,
);
const objectMetadataMap = generateObjectMetadataMap(
objectMetadataCollection,
);
@@ -89,6 +93,7 @@ export class GraphqlQueryFindOneResolverService {
relations,
limit,
authContext,
dataSource,
);
}

View File

@@ -18,7 +18,7 @@ import { computeDepth } from 'src/engine/api/rest/core/query-builder/utils/compu
import { parseCoreBatchPath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-batch-path.utils';
import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils';
import { Query } from 'src/engine/api/rest/core/types/query.type';
import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';

View File

@@ -2,12 +2,12 @@ import { Injectable } from '@nestjs/common';
import { Request } from 'express';
import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
import { MetadataQueryBuilderFactory } from 'src/engine/api/rest/metadata/query-builder/metadata-query-builder.factory';
import {
GraphqlApiType,
RestApiService,
} from 'src/engine/api/rest/rest-api.service';
import { MetadataQueryBuilderFactory } from 'src/engine/api/rest/metadata/query-builder/metadata-query-builder.factory';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
@Injectable()
export class RestApiMetadataService {

View File

@@ -21,6 +21,7 @@ export enum AppTokenType {
CodeChallenge = 'CODE_CHALLENGE',
AuthorizationCode = 'AUTHORIZATION_CODE',
PasswordResetToken = 'PASSWORD_RESET_TOKEN',
InvitationToken = 'INVITATION_TOKEN',
}
@Entity({ name: 'appToken', schema: 'core' })
@@ -37,8 +38,8 @@ export class AppToken {
@JoinColumn({ name: 'userId' })
user: Relation<User>;
@Column()
userId: string;
@Column({ nullable: true })
userId: string | null;
@ManyToOne(() => Workspace, (workspace) => workspace.appTokens, {
onDelete: 'CASCADE',
@@ -73,4 +74,7 @@ export class AppToken {
@Field()
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@Column({ nullable: true, type: 'jsonb' })
context: { email: string } | null;
}

View File

@@ -12,7 +12,8 @@ import { MicrosoftAuthController } from 'src/engine/core-modules/auth/controller
import { VerifyAuthController } from 'src/engine/core-modules/auth/controllers/verify-auth.controller';
import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
@@ -50,6 +51,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
ConnectedAccountWorkspaceEntity,
]),
HttpModule,
TokenModule,
UserWorkspaceModule,
WorkspaceModule,
OnboardingModule,
@@ -65,9 +67,9 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
providers: [
SignInUpService,
AuthService,
TokenService,
JwtAuthStrategy,
AuthResolver,
TokenService,
GoogleAPIsService,
AppTokenService,
],

View File

@@ -1,17 +1,17 @@
import { CanActivate } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { CanActivate } from '@nestjs/common';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { CaptchaGuard } from 'src/engine/core-modules/captcha/captcha.guard';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthResolver } from './auth.resolver';
import { TokenService } from './services/token.service';
import { AuthService } from './services/auth.service';
import { TokenService } from './token/services/token.service';
describe('AuthResolver', () => {
let resolver: AuthResolver;

View File

@@ -37,7 +37,7 @@ import { VerifyInput } from './dto/verify.input';
import { WorkspaceInviteHashValid } from './dto/workspace-invite-hash-valid.entity';
import { WorkspaceInviteHashValidInput } from './dto/workspace-invite-hash.input';
import { AuthService } from './services/auth.service';
import { TokenService } from './services/token.service';
import { TokenService } from './token/services/token.service';
@Resolver()
@UseFilters(AuthGraphqlApiExceptionFilter)

View File

@@ -17,10 +17,10 @@ import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters
import { GoogleAPIsOauthExchangeCodeForTokenGuard } from 'src/engine/core-modules/auth/guards/google-apis-oauth-exchange-code-for-token.guard';
import { GoogleAPIsOauthRequestCodeGuard } from 'src/engine/core-modules/auth/guards/google-apis-oauth-request-code.guard';
import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service';
import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
import { GoogleAPIsRequest } from 'src/engine/core-modules/auth/types/google-api-request.type';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
@Controller('auth/google-apis')
@UseFilters(AuthRestApiExceptionFilter)

View File

@@ -13,8 +13,8 @@ import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters
import { GoogleOauthGuard } from 'src/engine/core-modules/auth/guards/google-oauth.guard';
import { GoogleProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/google-provider-enabled.guard';
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
import { GoogleRequest } from 'src/engine/core-modules/auth/strategies/google.auth.strategy';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
@Controller('auth/google')
@UseFilters(AuthRestApiExceptionFilter)
@@ -34,8 +34,14 @@ export class GoogleAuthController {
@Get('redirect')
@UseGuards(GoogleProviderEnabledGuard, GoogleOauthGuard)
async googleAuthRedirect(@Req() req: GoogleRequest, @Res() res: Response) {
const { firstName, lastName, email, picture, workspaceInviteHash } =
req.user;
const {
firstName,
lastName,
email,
picture,
workspaceInviteHash,
workspacePersonalInviteToken,
} = req.user;
const user = await this.authService.signInUp({
email,
@@ -43,6 +49,7 @@ export class GoogleAuthController {
lastName,
picture,
workspaceInviteHash,
workspacePersonalInviteToken,
fromSSO: true,
});

View File

@@ -14,8 +14,8 @@ import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters
import { MicrosoftOAuthGuard } from 'src/engine/core-modules/auth/guards/microsoft-oauth.guard';
import { MicrosoftProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/microsoft-provider-enabled.guard';
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
import { MicrosoftRequest } from 'src/engine/core-modules/auth/strategies/microsoft.auth.strategy';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
@Controller('auth/microsoft')
@UseFilters(AuthRestApiExceptionFilter)
@@ -39,8 +39,14 @@ export class MicrosoftAuthController {
@Req() req: MicrosoftRequest,
@Res() res: Response,
) {
const { firstName, lastName, email, picture, workspaceInviteHash } =
req.user;
const {
firstName,
lastName,
email,
picture,
workspaceInviteHash,
workspacePersonalInviteToken,
} = req.user;
const user = await this.authService.signInUp({
email,
@@ -48,6 +54,7 @@ export class MicrosoftAuthController {
lastName,
picture,
workspaceInviteHash,
workspacePersonalInviteToken,
fromSSO: true,
});

View File

@@ -1,7 +1,7 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
import { VerifyAuthController } from './verify-auth.controller';

View File

@@ -4,7 +4,7 @@ import { Verify } from 'src/engine/core-modules/auth/dto/verify.entity';
import { VerifyInput } from 'src/engine/core-modules/auth/dto/verify.input';
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
@Controller('auth/verify')
@UseFilters(AuthRestApiExceptionFilter)

View File

@@ -19,6 +19,11 @@ export class SignUpInput {
@IsOptional()
workspaceInviteHash?: string;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
workspacePersonalInviteToken?: string;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()

View File

@@ -0,0 +1,12 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
@ArgsType()
export class WorkspaceInviteTokenInput {
@Field(() => String)
@IsString()
@IsNotEmpty()
@MinLength(10)
inviteToken: string;
}

View File

@@ -12,11 +12,20 @@ export class GoogleOauthGuard extends AuthGuard('google') {
async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest();
const workspaceInviteHash = request.query.inviteHash;
const workspacePersonalInviteToken = request.query.inviteToken;
if (workspaceInviteHash && typeof workspaceInviteHash === 'string') {
request.params.workspaceInviteHash = workspaceInviteHash;
}
if (
workspacePersonalInviteToken &&
typeof workspacePersonalInviteToken === 'string'
) {
request.params.workspacePersonalInviteToken =
workspacePersonalInviteToken;
}
return (await super.canActivate(context)) as boolean;
}
}

View File

@@ -12,11 +12,20 @@ export class MicrosoftOAuthGuard extends AuthGuard('microsoft') {
async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest();
const workspaceInviteHash = request.query.inviteHash;
const workspacePersonalInviteToken = request.query.inviteToken;
if (workspaceInviteHash && typeof workspaceInviteHash === 'string') {
request.params.workspaceInviteHash = workspaceInviteHash;
}
if (
workspacePersonalInviteToken &&
typeof workspacePersonalInviteToken === 'string'
) {
request.params.workspacePersonalInviteToken =
workspacePersonalInviteToken;
}
return (await super.canActivate(context)) as boolean;
}
}

View File

@@ -1,17 +1,17 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { User } from 'src/engine/core-modules/user/user.entity';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
import { AuthService } from './auth.service';
import { TokenService } from './token.service';
describe('AuthService', () => {
let service: AuthService;

View File

@@ -32,14 +32,13 @@ import { UserExists } from 'src/engine/core-modules/auth/dto/user-exists.entity'
import { Verify } from 'src/engine/core-modules/auth/dto/verify.entity';
import { WorkspaceInviteHashValid } from 'src/engine/core-modules/auth/dto/workspace-invite-hash-valid.entity';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { TokenService } from './token.service';
@Injectable()
export class AuthService {
constructor(
@@ -94,6 +93,7 @@ export class AuthService {
email,
password,
workspaceInviteHash,
workspacePersonalInviteToken,
firstName,
lastName,
picture,
@@ -104,6 +104,7 @@ export class AuthService {
firstName?: string | null;
lastName?: string | null;
workspaceInviteHash?: string | null;
workspacePersonalInviteToken?: string | null;
picture?: string | null;
fromSSO: boolean;
}) {
@@ -113,6 +114,7 @@ export class AuthService {
firstName,
lastName,
workspaceInviteHash,
workspacePersonalInviteToken,
picture,
fromSSO,
});

View File

@@ -9,6 +9,7 @@ import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/use
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
describe('SignInUpService', () => {
let service: SignInUpService;
@@ -29,6 +30,10 @@ describe('SignInUpService', () => {
provide: getRepositoryToken(User, 'core'),
useValue: {},
},
{
provide: getRepositoryToken(AppToken, 'core'),
useValue: {},
},
{
provide: UserWorkspaceService,
useValue: {},

View File

@@ -5,6 +5,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import FileType from 'file-type';
import { Repository } from 'typeorm';
import { v4 } from 'uuid';
import { isDefined } from 'class-validator';
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
@@ -27,6 +28,7 @@ import {
} from 'src/engine/core-modules/workspace/workspace.entity';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { getImageBufferFromUrl } from 'src/utils/image';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
export type SignInUpServiceInput = {
email: string;
@@ -34,6 +36,7 @@ export type SignInUpServiceInput = {
firstName?: string | null;
lastName?: string | null;
workspaceInviteHash?: string | null;
workspacePersonalInviteToken?: string | null;
picture?: string | null;
fromSSO: boolean;
};
@@ -45,6 +48,8 @@ export class SignInUpService {
private readonly fileUploadService: FileUploadService,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(AppToken, 'core')
private readonly appTokenRepository: Repository<AppToken>,
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
private readonly userWorkspaceService: UserWorkspaceService,
@@ -56,6 +61,7 @@ export class SignInUpService {
async signInUp({
email,
workspaceInviteHash,
workspacePersonalInviteToken,
password,
firstName,
lastName,
@@ -111,6 +117,7 @@ export class SignInUpService {
email,
passwordHash,
workspaceInviteHash,
workspacePersonalInviteToken,
firstName,
lastName,
picture,
@@ -134,6 +141,7 @@ export class SignInUpService {
email,
passwordHash,
workspaceInviteHash,
workspacePersonalInviteToken,
firstName,
lastName,
picture,
@@ -141,19 +149,25 @@ export class SignInUpService {
}: {
email: string;
passwordHash: string | undefined;
workspaceInviteHash: string;
workspaceInviteHash: string | null;
workspacePersonalInviteToken: string | null | undefined;
firstName: string;
lastName: string;
picture: SignInUpServiceInput['picture'];
existingUser: User | null;
}) {
const workspace = await this.workspaceRepository.findOneBy({
inviteHash: workspaceInviteHash,
const isNewUser = !isDefined(existingUser);
let user = existingUser;
const workspace = await this.findWorkspaceAndValidateInvitation({
workspacePersonalInviteToken,
workspaceInviteHash,
email,
});
if (!workspace) {
throw new AuthException(
'Invit hash is invalid',
'Workspace not found',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
@@ -165,32 +179,76 @@ export class SignInUpService {
);
}
if (existingUser) {
const updatedUser = await this.userWorkspaceService.addUserToWorkspace(
existingUser,
workspace,
);
if (isNewUser) {
const imagePath = await this.uploadPicture(picture, workspace.id);
return Object.assign(existingUser, updatedUser);
const userToCreate = this.userRepository.create({
email: email,
firstName: firstName,
lastName: lastName,
defaultAvatarUrl: imagePath,
canImpersonate: false,
passwordHash,
defaultWorkspace: workspace,
});
user = await this.userRepository.save(userToCreate);
}
const imagePath = await this.uploadPicture(picture, workspace.id);
if (!user) {
throw new AuthException(
'User not found',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
const userToCreate = this.userRepository.create({
email: email,
firstName: firstName,
lastName: lastName,
defaultAvatarUrl: imagePath,
canImpersonate: false,
passwordHash,
defaultWorkspace: workspace,
});
const updatedUser = workspacePersonalInviteToken
? await this.userWorkspaceService.addUserToWorkspaceByInviteToken(
workspacePersonalInviteToken,
user,
)
: await this.userWorkspaceService.addUserToWorkspace(user, workspace);
const user = await this.userRepository.save(userToCreate);
if (isNewUser) {
await this.activateOnboardingForNewUser(user, workspace, {
firstName,
lastName,
});
}
await this.userWorkspaceService.create(user.id, workspace.id);
await this.userWorkspaceService.createWorkspaceMember(workspace.id, user);
return Object.assign(user, updatedUser);
}
private async findWorkspaceAndValidateInvitation({
workspacePersonalInviteToken,
workspaceInviteHash,
email,
}) {
if (!workspacePersonalInviteToken && !workspaceInviteHash) {
throw new Error('No invite token or hash provided');
}
if (!workspacePersonalInviteToken && workspaceInviteHash) {
return (
(await this.workspaceRepository.findOneBy({
inviteHash: workspaceInviteHash,
})) ?? undefined
);
}
const appToken = await this.userWorkspaceService.validateInvitation(
workspacePersonalInviteToken,
email,
);
return appToken?.workspace;
}
private async activateOnboardingForNewUser(
user: User,
workspace: Workspace,
{ firstName, lastName }: { firstName: string; lastName: string },
) {
await this.onboardingService.setOnboardingConnectAccountPending({
userId: user.id,
workspaceId: workspace.id,
@@ -204,8 +262,6 @@ export class SignInUpService {
value: true,
});
}
return user;
}
private async signUpOnNewWorkspace({

View File

@@ -16,6 +16,7 @@ export type GoogleRequest = Omit<
email: string;
picture: string | null;
workspaceInviteHash?: string;
workspacePersonalInviteToken?: string;
};
};
@@ -36,6 +37,12 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
...options,
state: JSON.stringify({
workspaceInviteHash: req.params.workspaceInviteHash,
...(req.params.workspacePersonalInviteToken
? {
workspacePersonalInviteToken:
req.params.workspacePersonalInviteToken,
}
: {}),
}),
};
@@ -61,6 +68,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
lastName: name.familyName,
picture: photos?.[0]?.value,
workspaceInviteHash: state.workspaceInviteHash,
workspacePersonalInviteToken: state.workspacePersonalInviteToken,
};
done(null, user);

View File

@@ -20,6 +20,7 @@ export type MicrosoftRequest = Omit<
email: string;
picture: string | null;
workspaceInviteHash?: string;
workspacePersonalInviteToken?: string;
};
};
@@ -40,6 +41,12 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') {
...options,
state: JSON.stringify({
workspaceInviteHash: req.params.workspaceInviteHash,
...(req.params.workspacePersonalInviteToken
? {
workspacePersonalInviteToken:
req.params.workspacePersonalInviteToken,
}
: {}),
}),
};
@@ -75,6 +82,7 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') {
lastName: name.familyName,
picture: photos?.[0]?.value,
workspaceInviteHash: state.workspaceInviteHash,
workspacePersonalInviteToken: state.workspacePersonalInviteToken,
};
done(null, user);

View File

@@ -175,6 +175,33 @@ export class TokenService {
};
}
async generateInvitationToken(workspaceId: string, email: string) {
const expiresIn = this.environmentService.get(
'INVITATION_TOKEN_EXPIRES_IN',
);
if (!expiresIn) {
throw new AuthException(
'Expiration time for invitation token is not set',
AuthExceptionCode.INTERNAL_SERVER_ERROR,
);
}
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
const invitationToken = this.appTokenRepository.create({
workspaceId,
expiresAt,
type: AppTokenType.InvitationToken,
value: crypto.randomBytes(32).toString('hex'),
context: {
email,
},
});
return this.appTokenRepository.save(invitationToken);
}
async generateLoginToken(email: string): Promise<AuthToken> {
const secret = this.environmentService.get('LOGIN_TOKEN_SECRET');
const expiresIn = this.environmentService.get('LOGIN_TOKEN_EXPIRES_IN');
@@ -416,7 +443,7 @@ export class TokenService {
},
});
if (!codeChallengeAppToken) {
if (!codeChallengeAppToken || !codeChallengeAppToken.userId) {
throw new AuthException(
'code verifier doesnt match the challenge',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
@@ -750,7 +777,7 @@ export class TokenService {
},
});
if (!token) {
if (!token || !token.userId) {
throw new AuthException(
'Token is invalid',
AuthExceptionCode.FORBIDDEN_EXCEPTION,

View File

@@ -0,0 +1,26 @@
/* eslint-disable no-restricted-imports */
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
import { EmailModule } from 'src/engine/core-modules/email/email.module';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
@Module({
imports: [
JwtModule,
TypeOrmModule.forFeature([User, AppToken, Workspace], 'core'),
TypeORMModule,
DataSourceModule,
EmailModule,
],
providers: [TokenService, JwtAuthStrategy],
exports: [TokenService],
})
export class TokenModule {}

View File

@@ -13,19 +13,19 @@ import { BillingService } from 'src/engine/core-modules/billing/services/billing
import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Module({
imports: [
FeatureFlagModule,
StripeModule,
UserWorkspaceModule,
TypeOrmModule.forFeature(
[
BillingSubscription,
BillingSubscriptionItem,
Workspace,
UserWorkspace,
FeatureFlagEntity,
],
'core',

View File

@@ -6,10 +6,10 @@ import { Repository } from 'typeorm';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { assert } from 'src/utils/assert';
export enum WebhookEvent {
@@ -24,10 +24,11 @@ export class BillingPortalWorkspaceService {
protected readonly logger = new Logger(BillingPortalWorkspaceService.name);
constructor(
private readonly stripeService: StripeService,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly environmentService: EnvironmentService,
@InjectRepository(BillingSubscription, 'core')
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
@InjectRepository(UserWorkspace, 'core')
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
private readonly billingSubscriptionService: BillingSubscriptionService,
) {}
@@ -42,8 +43,9 @@ export class BillingPortalWorkspaceService {
? frontBaseUrl + successUrlPath
: frontBaseUrl;
const quantity =
(await this.userWorkspaceService.getUserCount(workspace.id)) || 1;
const quantity = await this.userWorkspaceRepository.countBy({
workspaceId: workspace.id,
});
const stripeCustomerId = (
await this.billingSubscriptionRepository.findOneBy({

View File

@@ -39,6 +39,7 @@ import { llmTracingModuleFactory } from 'src/engine/core-modules/llm-tracing/llm
import { ServerlessModule } from 'src/engine/core-modules/serverless/serverless.module';
import { serverlessModuleFactory } from 'src/engine/core-modules/serverless/serverless-module.factory';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module';
import { FileModule } from './file/file.module';
import { ClientConfigModule } from './client-config/client-config.module';
@@ -59,6 +60,7 @@ import { AnalyticsModule } from './analytics/analytics.module';
TimelineCalendarEventModule,
UserModule,
WorkspaceModule,
WorkspaceInvitationModule,
AISQLQueryModule,
PostgresCredentialsModule,
WorkflowTriggerApiModule,
@@ -114,6 +116,7 @@ import { AnalyticsModule } from './analytics/analytics.module';
TimelineCalendarEventModule,
UserModule,
WorkspaceModule,
WorkspaceInvitationModule,
],
})
export class CoreEngineModule {}

View File

@@ -150,6 +150,10 @@ export class EnvironmentVariables {
@IsOptional()
FILE_TOKEN_EXPIRES_IN = '1d';
@IsDuration()
@IsOptional()
INVITATION_TOKEN_EXPIRES_IN = '30d';
// Auth
@IsUrl({ require_tld: false })
@IsOptional()

View File

@@ -6,6 +6,7 @@ import { DataSeedDemoWorkspaceModule } from 'src/database/commands/data-seed-dem
import { DataSeedDemoWorkspaceJob } from 'src/database/commands/data-seed-demo-workspace/jobs/data-seed-demo-workspace.job';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { WorkspaceQueryRunnerJobModule } from 'src/engine/api/graphql/workspace-query-runner/jobs/workspace-query-runner-job.module';
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
import { UpdateSubscriptionJob } from 'src/engine/core-modules/billing/jobs/update-subscription.job';
import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module';
@@ -39,6 +40,7 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
BillingModule,
UserWorkspaceModule,
WorkspaceModule,
AuthModule,
MessagingModule,
CalendarModule,
CalendarEventParticipantManagerModule,

View File

@@ -1,9 +1,9 @@
import { Test, TestingModule } from '@nestjs/testing';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { OpenApiService } from 'src/engine/core-modules/open-api/open-api.service';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
describe('OpenApiService', () => {
let service: OpenApiService;

View File

@@ -3,7 +3,8 @@ import { Injectable } from '@nestjs/common';
import { Request } from 'express';
import { OpenAPIV3_1 } from 'openapi-types';
import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { baseSchema } from 'src/engine/core-modules/open-api/utils/base-schema.utils';
import {
computeMetadataSchemaComponents,
@@ -33,7 +34,6 @@ import {
getFindOneResponse200,
getUpdateOneResponse200,
} from 'src/engine/core-modules/open-api/utils/responses.utils';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { capitalize } from 'src/utils/capitalize';
import { getServerUrl } from 'src/utils/get-server-url';

View File

@@ -6,18 +6,24 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { User } from 'src/engine/core-modules/user/user.entity';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module';
@Module({
imports: [
NestjsQueryGraphQLModule.forFeature({
imports: [
NestjsQueryTypeOrmModule.forFeature([User, UserWorkspace], 'core'),
NestjsQueryTypeOrmModule.forFeature(
[User, UserWorkspace, AppToken],
'core',
),
TypeORMModule,
DataSourceModule,
WorkspaceDataSourceModule,
WorkspaceInvitationModule,
],
services: [UserWorkspaceService],
}),

View File

@@ -11,6 +11,8 @@ import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { WorkspaceInviteTokenInput } from 'src/engine/core-modules/auth/dto/workspace-invite-token.input';
@UseGuards(WorkspaceAuthGuard)
@Resolver(() => UserWorkspace)
@@ -18,9 +20,8 @@ export class UserWorkspaceResolver {
constructor(
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly workspaceInvitationService: WorkspaceInvitationService,
) {}
@Mutation(() => User)
@@ -36,6 +37,22 @@ export class UserWorkspaceResolver {
return;
}
await this.workspaceInvitationService.invalidateWorkspaceInvitation(
workspace.id,
user.email,
);
return await this.userWorkspaceService.addUserToWorkspace(user, workspace);
}
@Mutation(() => User)
async addUserToWorkspaceByInviteToken(
@AuthUser() user: User,
@Args() workspaceInviteTokenInput: WorkspaceInviteTokenInput,
) {
return this.userWorkspaceService.addUserToWorkspaceByInviteToken(
workspaceInviteTokenInput.inviteToken,
user,
);
}
}

View File

@@ -13,6 +13,11 @@ import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { assert } from 'src/utils/assert';
import {
AppToken,
AppTokenType,
} from 'src/engine/core-modules/app-token/app-token.entity';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
constructor(
@@ -20,8 +25,11 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
@InjectRepository(AppToken, 'core')
private readonly appTokenRepository: Repository<AppToken>,
private readonly dataSourceService: DataSourceService,
private readonly typeORMService: TypeORMService,
private readonly workspaceInvitationService: WorkspaceInvitationService,
private workspaceEventEmitter: WorkspaceEventEmitter,
) {
super(userWorkspaceRepository);
@@ -105,6 +113,41 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
});
}
async validateInvitation(inviteToken: string, email: string) {
const appToken = await this.appTokenRepository.findOne({
where: {
value: inviteToken,
type: AppTokenType.InvitationToken,
},
relations: ['workspace'],
});
if (!appToken) {
throw new Error('Invalid invitation token');
}
if (appToken.context?.email !== email) {
throw new Error('Email does not match the invitation');
}
if (new Date(appToken.expiresAt) < new Date()) {
throw new Error('Invitation expired');
}
return appToken;
}
async addUserToWorkspaceByInviteToken(inviteToken: string, user: User) {
const appToken = await this.validateInvitation(inviteToken, user.email);
await this.workspaceInvitationService.invalidateWorkspaceInvitation(
appToken.workspace.id,
user.email,
);
return await this.addUserToWorkspace(user, appToken.workspace);
}
public async getUserCount(workspaceId): Promise<number | undefined> {
return await this.userWorkspaceRepository.countBy({
workspaceId,
@@ -120,4 +163,18 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
workspaceId,
});
}
async checkUserWorkspaceExistsByEmail(email: string, workspaceId: string) {
return this.userWorkspaceRepository.exists({
where: {
workspaceId,
user: {
email,
},
},
relations: {
user: true,
},
});
}
}

View File

@@ -3,7 +3,7 @@ import { ArgsType, Field } from '@nestjs/graphql';
import { ArrayUnique, IsArray, IsEmail } from 'class-validator';
@ArgsType()
export class SendInviteLinkInput {
export class SendInvitationsInput {
@Field(() => [String])
@IsArray()
@IsEmail({}, { each: true })

View File

@@ -0,0 +1,17 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { WorkspaceInvitation } from 'src/engine/core-modules/workspace-invitation/dtos/workspace-invitation.dto';
@ObjectType()
export class SendInvitationsOutput {
@Field(() => Boolean, {
description: 'Boolean that confirms query was dispatched',
})
success: boolean;
@Field(() => [String])
errors: Array<string>;
@Field(() => [WorkspaceInvitation])
result: Array<WorkspaceInvitation>;
}

View File

@@ -0,0 +1,17 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('WorkspaceInvitation')
export class WorkspaceInvitation {
@IDField(() => UUIDScalarType)
id: string;
@Field({ nullable: false })
email: string;
@Field({ nullable: false })
expiresAt: Date;
}

View File

@@ -0,0 +1,55 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { WorkspaceInvitationService } from './workspace-invitation.service';
describe('WorkspaceInvitationService', () => {
let service: WorkspaceInvitationService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
WorkspaceInvitationService,
{
provide: getRepositoryToken(AppToken, 'core'),
useValue: {},
},
{
provide: EnvironmentService,
useValue: {},
},
{
provide: EmailService,
useValue: {},
},
{
provide: TokenService,
useValue: {},
},
{
provide: getRepositoryToken(UserWorkspace, 'core'),
useValue: {},
},
{
provide: OnboardingService,
useValue: {},
},
],
}).compile();
service = module.get<WorkspaceInvitationService>(
WorkspaceInvitationService,
);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@@ -0,0 +1,293 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { render } from '@react-email/render';
import { SendInviteLinkEmail } from 'twenty-emails';
import { IsNull, Repository } from 'typeorm';
import {
AppToken,
AppTokenType,
} from 'src/engine/core-modules/app-token/app-token.entity';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { User } from 'src/engine/core-modules/user/user.entity';
import { SendInvitationsOutput } from 'src/engine/core-modules/workspace-invitation/dtos/send-invitations.output';
import {
WorkspaceInvitationException,
WorkspaceInvitationExceptionCode,
} from 'src/engine/core-modules/workspace-invitation/workspace-invitation.exception';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
export class WorkspaceInvitationService {
constructor(
@InjectRepository(AppToken, 'core')
private readonly appTokenRepository: Repository<AppToken>,
private readonly environmentService: EnvironmentService,
private readonly emailService: EmailService,
private readonly tokenService: TokenService,
@InjectRepository(UserWorkspace, 'core')
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
private readonly onboardingService: OnboardingService,
) {}
private async getOneWorkspaceInvitation(workspaceId: string, email: string) {
return await this.appTokenRepository
.createQueryBuilder('appToken')
.where('"appToken"."workspaceId" = :workspaceId', {
workspaceId,
})
.andWhere('"appToken".type = :type', {
type: AppTokenType.InvitationToken,
})
.andWhere('"appToken".context->>\'email\' = :email', { email })
.getOne();
}
castAppTokenToWorkspaceInvitation(appToken: AppToken) {
if (appToken.type !== AppTokenType.InvitationToken) {
throw new WorkspaceInvitationException(
`Token type must be "${AppTokenType.InvitationToken}"`,
WorkspaceInvitationExceptionCode.INVALID_APP_TOKEN_TYPE,
);
}
if (!appToken.context?.email) {
throw new WorkspaceInvitationException(
`Invitation corrupted: Missing email in context`,
WorkspaceInvitationExceptionCode.INVITATION_CORRUPTED,
);
}
return {
id: appToken.id,
email: appToken.context.email,
expiresAt: appToken.expiresAt,
};
}
async createWorkspaceInvitation(email: string, workspace: Workspace) {
const maybeWorkspaceInvitation = await this.getOneWorkspaceInvitation(
workspace.id,
email.toLowerCase(),
);
if (maybeWorkspaceInvitation) {
throw new WorkspaceInvitationException(
`${email} already invited`,
WorkspaceInvitationExceptionCode.INVITATION_ALREADY_EXIST,
);
}
const isUserAlreadyInWorkspace = await this.userWorkspaceRepository.exists({
where: {
workspaceId: workspace.id,
user: {
email,
},
},
relations: {
user: true,
},
});
if (isUserAlreadyInWorkspace) {
throw new WorkspaceInvitationException(
`${email} is already in the workspace`,
WorkspaceInvitationExceptionCode.USER_ALREADY_EXIST,
);
}
return this.tokenService.generateInvitationToken(workspace.id, email);
}
async loadWorkspaceInvitations(workspace: Workspace) {
const appTokens = await this.appTokenRepository.find({
where: {
workspaceId: workspace.id,
type: AppTokenType.InvitationToken,
deletedAt: IsNull(),
},
select: {
value: false,
},
});
return appTokens.map(this.castAppTokenToWorkspaceInvitation);
}
async deleteWorkspaceInvitation(appTokenId: string, workspaceId: string) {
const appToken = await this.appTokenRepository.findOne({
where: {
id: appTokenId,
workspaceId,
type: AppTokenType.InvitationToken,
},
});
if (!appToken) {
return 'error';
}
await this.appTokenRepository.delete(appToken.id);
return 'success';
}
async invalidateWorkspaceInvitation(workspaceId: string, email: string) {
const appToken = await this.getOneWorkspaceInvitation(workspaceId, email);
if (appToken) {
await this.appTokenRepository.delete(appToken.id);
}
}
async resendWorkspaceInvitation(
appTokenId: string,
workspace: Workspace,
sender: User,
) {
const appToken = await this.appTokenRepository.findOne({
where: {
id: appTokenId,
workspaceId: workspace.id,
type: AppTokenType.InvitationToken,
},
});
if (!appToken || !appToken.context || !('email' in appToken.context)) {
throw new WorkspaceInvitationException(
'Invalid appToken',
WorkspaceInvitationExceptionCode.INVALID_INVITATION,
);
}
await this.appTokenRepository.delete(appToken.id);
return this.sendInvitations([appToken.context.email], workspace, sender);
}
async sendInvitations(
emails: string[],
workspace: Workspace,
sender: User,
usePersonalInvitation = true,
): Promise<SendInvitationsOutput> {
if (!workspace?.inviteHash) {
return {
success: false,
errors: ['Workspace invite hash not found'],
result: [],
};
}
const invitationsPr = await Promise.allSettled(
emails.map(async (email) => {
if (usePersonalInvitation) {
const appToken = await this.createWorkspaceInvitation(
email,
workspace,
);
if (!appToken.context?.email) {
throw new WorkspaceInvitationException(
'Invalid email',
WorkspaceInvitationExceptionCode.EMAIL_MISSING,
);
}
return {
isPersonalInvitation: true as const,
appToken,
email: appToken.context.email,
};
}
return {
isPersonalInvitation: false as const,
email,
};
}),
);
const frontBaseURL = this.environmentService.get('FRONT_BASE_URL');
for (const invitation of invitationsPr) {
if (invitation.status === 'fulfilled') {
const link = new URL(`${frontBaseURL}/invite/${workspace?.inviteHash}`);
if (invitation.value.isPersonalInvitation) {
link.searchParams.set('inviteToken', invitation.value.appToken.value);
}
const emailData = {
link: link.toString(),
workspace: { name: workspace.displayName, logo: workspace.logo },
sender: { email: sender.email, firstName: sender.firstName },
serverUrl: this.environmentService.get('SERVER_URL'),
};
const emailTemplate = SendInviteLinkEmail(emailData);
const html = render(emailTemplate, {
pretty: true,
});
const text = render(emailTemplate, {
plainText: true,
});
await this.emailService.send({
from: `${this.environmentService.get(
'EMAIL_FROM_NAME',
)} <${this.environmentService.get('EMAIL_FROM_ADDRESS')}>`,
to: invitation.value.email,
subject: 'Join your team on Twenty',
text,
html,
});
}
}
await this.onboardingService.setOnboardingInviteTeamPending({
workspaceId: workspace.id,
value: false,
});
const result = invitationsPr.reduce<{
errors: string[];
result: ReturnType<
typeof this.workspaceInvitationService.createWorkspaceInvitation
>['status'] extends 'rejected'
? never
: ReturnType<
typeof this.workspaceInvitationService.appTokenToWorkspaceInvitation
>;
}>(
(acc, invitation) => {
if (invitation.status === 'rejected') {
acc.errors.push(invitation.reason?.message ?? 'Unknown error');
} else {
acc.result.push(
invitation.value.isPersonalInvitation
? this.castAppTokenToWorkspaceInvitation(
invitation.value.appToken,
)
: { email: invitation.value.email },
);
}
return acc;
},
{ errors: [], result: [] },
);
return {
success: result.errors.length === 0,
...result,
};
}
}

View File

@@ -0,0 +1,17 @@
import { CustomException } from 'src/utils/custom-exception';
export class WorkspaceInvitationException extends CustomException {
code: WorkspaceInvitationExceptionCode;
constructor(message: string, code: WorkspaceInvitationExceptionCode) {
super(message, code);
}
}
export enum WorkspaceInvitationExceptionCode {
INVALID_APP_TOKEN_TYPE = 'INVALID_APP_TOKEN_TYPE',
INVITATION_CORRUPTED = 'INVITATION_CORRUPTED',
INVITATION_ALREADY_EXIST = 'INVITATION_ALREADY_EXIST',
USER_ALREADY_EXIST = 'USER_ALREADY_EXIST',
INVALID_INVITATION = 'INVALID_INVITATION',
EMAIL_MISSING = 'EMAIL_MISSING',
}

View File

@@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { WorkspaceInvitationResolver } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.resolver';
@Module({
imports: [
NestjsQueryTypeOrmModule.forFeature([AppToken, UserWorkspace], 'core'),
TokenModule,
OnboardingModule,
],
exports: [WorkspaceInvitationService],
providers: [WorkspaceInvitationService, WorkspaceInvitationResolver],
})
export class WorkspaceInvitationModule {}

View File

@@ -0,0 +1,66 @@
import { UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { WorkspaceInvitation } from 'src/engine/core-modules/workspace-invitation/dtos/workspace-invitation.dto';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { User } from 'src/engine/core-modules/user/user.entity';
import { SendInvitationsOutput } from 'src/engine/core-modules/workspace-invitation/dtos/send-invitations.output';
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { SendInvitationsInput } from './dtos/send-invitations.input';
@UseGuards(WorkspaceAuthGuard)
@Resolver()
export class WorkspaceInvitationResolver {
constructor(
private readonly workspaceInvitationService: WorkspaceInvitationService,
) {}
@Mutation(() => String)
async deleteWorkspaceInvitation(
@Args('appTokenId') appTokenId: string,
@AuthWorkspace() { id: workspaceId }: Workspace,
) {
return this.workspaceInvitationService.deleteWorkspaceInvitation(
appTokenId,
workspaceId,
);
}
@Mutation(() => SendInvitationsOutput)
@UseGuards(UserAuthGuard)
async resendWorkspaceInvitation(
@Args('appTokenId') appTokenId: string,
@AuthWorkspace() workspace: Workspace,
@AuthUser() user: User,
) {
return this.workspaceInvitationService.resendWorkspaceInvitation(
appTokenId,
workspace,
user,
);
}
@Query(() => [WorkspaceInvitation])
async findWorkspaceInvitations(@AuthWorkspace() workspace: Workspace) {
return this.workspaceInvitationService.loadWorkspaceInvitations(workspace);
}
@Mutation(() => SendInvitationsOutput)
@UseGuards(UserAuthGuard)
async sendInvitations(
@Args() sendInviteLinkInput: SendInvitationsInput,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
): Promise<SendInvitationsOutput> {
return await this.workspaceInvitationService.sendInvitations(
sendInviteLinkInput.emails,
workspace,
user,
);
}
}

View File

@@ -1,9 +0,0 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class SendInviteLink {
@Field(() => Boolean, {
description: 'Boolean that confirms query was dispatched',
})
success: boolean;
}

View File

@@ -11,6 +11,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { WorkspaceService } from './workspace.service';
@@ -61,6 +62,10 @@ describe('WorkspaceService', () => {
provide: OnboardingService,
useValue: {},
},
{
provide: WorkspaceInvitationService,
useValue: {},
},
],
}).compile();

View File

@@ -1,22 +1,17 @@
import { BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ModuleRef } from '@nestjs/core';
import assert from 'assert';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { render } from '@react-email/render';
import { SendInviteLinkEmail } from 'twenty-emails';
import { Repository } from 'typeorm';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { ActivateWorkspaceInput } from 'src/engine/core-modules/workspace/dtos/activate-workspace-input';
import { SendInviteLink } from 'src/engine/core-modules/workspace/dtos/send-invite-link.entity';
import {
Workspace,
WorkspaceActivationStatus,
@@ -25,6 +20,7 @@ import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
export class WorkspaceService extends TypeOrmQueryService<Workspace> {
private userWorkspaceService: UserWorkspaceService;
constructor(
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
@@ -33,13 +29,13 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
@InjectRepository(UserWorkspace, 'core')
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
private readonly workspaceManagerService: WorkspaceManagerService,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly billingSubscriptionService: BillingSubscriptionService,
private readonly environmentService: EnvironmentService,
private readonly emailService: EmailService,
private readonly onboardingService: OnboardingService,
private moduleRef: ModuleRef,
) {
super(workspaceRepository);
this.userWorkspaceService = this.moduleRef.get(UserWorkspaceService, {
strict: false,
});
}
async activateWorkspace(user: User, data: ActivateWorkspaceInput) {
@@ -66,7 +62,7 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
existingWorkspace.activationStatus !==
WorkspaceActivationStatus.PENDING_CREATION
) {
throw new Error('Worspace is not pending creation');
throw new Error('Workspace is not pending creation');
}
await this.workspaceRepository.update(user.defaultWorkspaceId, {
@@ -123,53 +119,6 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
await this.reassignOrRemoveUserDefaultWorkspace(workspaceId, userId);
}
async sendInviteLink(
emails: string[],
workspace: Workspace,
sender: User,
): Promise<SendInviteLink> {
if (!workspace?.inviteHash) {
return { success: false };
}
const frontBaseURL = this.environmentService.get('FRONT_BASE_URL');
const inviteLink = `${frontBaseURL}/invite/${workspace.inviteHash}`;
for (const email of emails) {
const emailData = {
link: inviteLink,
workspace: { name: workspace.displayName, logo: workspace.logo },
sender: { email: sender.email, firstName: sender.firstName },
serverUrl: this.environmentService.get('SERVER_URL'),
};
const emailTemplate = SendInviteLinkEmail(emailData);
const html = render(emailTemplate, {
pretty: true,
});
const text = render(emailTemplate, {
plainText: true,
});
await this.emailService.send({
from: `${this.environmentService.get(
'EMAIL_FROM_NAME',
)} <${this.environmentService.get('EMAIL_FROM_ADDRESS')}>`,
to: email,
subject: 'Join your team on Twenty',
text,
html,
});
}
await this.onboardingService.setOnboardingInviteTeamPending({
workspaceId: workspace.id,
value: false,
});
return { success: true };
}
private async reassignOrRemoveUserDefaultWorkspace(
workspaceId: string,
userId: string,

View File

@@ -18,6 +18,7 @@ import { WorkspaceResolver } from 'src/engine/core-modules/workspace/workspace.r
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { WorkspaceMetadataCacheModule } from 'src/engine/metadata-modules/workspace-metadata-cache/workspace-metadata-cache.module';
import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module';
import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module';
import { workspaceAutoResolverOpts } from './workspace.auto-resolver-opts';
import { Workspace } from './workspace.entity';
@@ -42,6 +43,7 @@ import { WorkspaceService } from './services/workspace.service';
DataSourceModule,
OnboardingModule,
TypeORMModule,
WorkspaceInvitationModule,
],
services: [WorkspaceService],
resolvers: workspaceAutoResolverOpts,

View File

@@ -19,8 +19,6 @@ import { FileService } from 'src/engine/core-modules/file/services/file.service'
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { ActivateWorkspaceInput } from 'src/engine/core-modules/workspace/dtos/activate-workspace-input';
import { SendInviteLink } from 'src/engine/core-modules/workspace/dtos/send-invite-link.entity';
import { SendInviteLinkInput } from 'src/engine/core-modules/workspace/dtos/send-invite-link.input';
import { UpdateWorkspaceInput } from 'src/engine/core-modules/workspace/dtos/update-workspace-input';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
@@ -138,18 +136,4 @@ export class WorkspaceResolver {
return workspace.logo ?? '';
}
@Mutation(() => SendInviteLink)
@UseGuards(UserAuthGuard)
async sendInviteLink(
@Args() sendInviteLinkInput: SendInviteLinkInput,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
): Promise<SendInviteLink> {
return await this.workspaceService.sendInviteLink(
sendInviteLinkInput.emails,
workspace,
user,
);
}
}

View File

@@ -1,6 +1,6 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
@Injectable()

View File

@@ -3,7 +3,7 @@ import { Injectable, NestMiddleware } from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { handleExceptionAndConvertToGraphQLError } from 'src/engine/utils/global-exception-handler.util';

View File

@@ -51,3 +51,4 @@ export * from './tooltip/OverflowingTextWithTooltip';
export * from './typography/components/H1Title';
export * from './typography/components/H2Title';
export * from './typography/components/H3Title';
export * from './typography/components/StyledText';

View File

@@ -0,0 +1,52 @@
import { ReactElement, ReactNode } from 'react';
import styled from '@emotion/styled';
type StyledTextProps = {
PrefixComponent?: ReactElement;
text: ReactNode;
color?: string;
};
export const StyledTextContent = styled.div`
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.regular};
overflow: hidden;
padding-left: 0;
white-space: nowrap;
`;
export const StyledTextWrapper = styled.div<{
color?: string;
}>`
--horizontal-padding: ${({ theme }) => theme.spacing(1)};
--vertical-padding: ${({ theme }) => theme.spacing(2)};
cursor: initial;
display: flex;
flex-direction: row;
font-size: ${({ theme }) => theme.font.size.sm};
gap: ${({ theme }) => theme.spacing(2)};
padding: var(--vertical-padding) 0;
color: ${({ theme, color }) => color ?? theme.font.color.primary};
`;
export const StyledText = ({
PrefixComponent,
text,
color,
}: StyledTextProps) => {
return (
<StyledTextWrapper color={color}>
{PrefixComponent ? PrefixComponent : null}
<StyledTextContent>{text}</StyledTextContent>
</StyledTextWrapper>
);
};