fix(): include workspace in reset password flow (#10617)

Fix #10586
This commit is contained in:
Antoine Moreaux
2025-03-03 16:47:33 +01:00
committed by GitHub
parent b8d944bd6e
commit 2325e0ae0f
13 changed files with 186 additions and 90 deletions

View File

@ -28,8 +28,8 @@ const documents = {
"\n mutation CreateOneFieldMetadataItem($input: CreateOneFieldMetadataInput!) {\n createOneField(input: $input) {\n id\n type\n name\n label\n description\n icon\n isCustom\n isActive\n isNullable\n createdAt\n updatedAt\n settings\n defaultValue\n options\n }\n }\n": types.CreateOneFieldMetadataItemDocument, "\n mutation CreateOneFieldMetadataItem($input: CreateOneFieldMetadataInput!) {\n createOneField(input: $input) {\n id\n type\n name\n label\n description\n icon\n isCustom\n isActive\n isNullable\n createdAt\n updatedAt\n settings\n defaultValue\n options\n }\n }\n": types.CreateOneFieldMetadataItemDocument,
"\n mutation CreateOneRelationMetadataItem(\n $input: CreateOneRelationMetadataInput!\n ) {\n createOneRelationMetadata(input: $input) {\n id\n relationType\n fromObjectMetadataId\n toObjectMetadataId\n fromFieldMetadataId\n toFieldMetadataId\n createdAt\n updatedAt\n }\n }\n": types.CreateOneRelationMetadataItemDocument, "\n mutation CreateOneRelationMetadataItem(\n $input: CreateOneRelationMetadataInput!\n ) {\n createOneRelationMetadata(input: $input) {\n id\n relationType\n fromObjectMetadataId\n toObjectMetadataId\n fromFieldMetadataId\n toFieldMetadataId\n createdAt\n updatedAt\n }\n }\n": types.CreateOneRelationMetadataItemDocument,
"\n mutation UpdateOneFieldMetadataItem(\n $idToUpdate: UUID!\n $updatePayload: UpdateFieldInput!\n ) {\n updateOneField(input: { id: $idToUpdate, update: $updatePayload }) {\n id\n type\n name\n label\n description\n icon\n isCustom\n isActive\n isNullable\n createdAt\n updatedAt\n settings\n isLabelSyncedWithName\n }\n }\n": types.UpdateOneFieldMetadataItemDocument, "\n mutation UpdateOneFieldMetadataItem(\n $idToUpdate: UUID!\n $updatePayload: UpdateFieldInput!\n ) {\n updateOneField(input: { id: $idToUpdate, update: $updatePayload }) {\n id\n type\n name\n label\n description\n icon\n isCustom\n isActive\n isNullable\n createdAt\n updatedAt\n settings\n isLabelSyncedWithName\n }\n }\n": types.UpdateOneFieldMetadataItemDocument,
"\n mutation UpdateOneObjectMetadataItem(\n $idToUpdate: UUID!\n $updatePayload: UpdateObjectPayload!\n ) {\n updateOneObject(input: { id: $idToUpdate, update: $updatePayload }) {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isActive\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n }\n }\n": types.UpdateOneObjectMetadataItemDocument, "\n mutation UpdateOneObjectMetadataItem(\n $idToUpdate: UUID!\n $updatePayload: UpdateObjectPayload!\n ) {\n updateOneObject(input: { id: $idToUpdate, update: $updatePayload }) {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isActive\n isSearchable\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n }\n }\n": types.UpdateOneObjectMetadataItemDocument,
"\n mutation DeleteOneObjectMetadataItem($idToDelete: UUID!) {\n deleteOneObject(input: { id: $idToDelete }) {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isActive\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n }\n }\n": types.DeleteOneObjectMetadataItemDocument, "\n mutation DeleteOneObjectMetadataItem($idToDelete: UUID!) {\n deleteOneObject(input: { id: $idToDelete }) {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isActive\n isSearchable\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n }\n }\n": types.DeleteOneObjectMetadataItemDocument,
"\n mutation DeleteOneFieldMetadataItem($idToDelete: UUID!) {\n deleteOneField(input: { id: $idToDelete }) {\n id\n type\n name\n label\n description\n icon\n isCustom\n isActive\n isNullable\n createdAt\n updatedAt\n settings\n }\n }\n": types.DeleteOneFieldMetadataItemDocument, "\n mutation DeleteOneFieldMetadataItem($idToDelete: UUID!) {\n deleteOneField(input: { id: $idToDelete }) {\n id\n type\n name\n label\n description\n icon\n isCustom\n isActive\n isNullable\n createdAt\n updatedAt\n settings\n }\n }\n": types.DeleteOneFieldMetadataItemDocument,
"\n mutation DeleteOneRelationMetadataItem($idToDelete: UUID!) {\n deleteOneRelation(input: { id: $idToDelete }) {\n id\n }\n }\n": types.DeleteOneRelationMetadataItemDocument, "\n mutation DeleteOneRelationMetadataItem($idToDelete: UUID!) {\n deleteOneRelation(input: { id: $idToDelete }) {\n id\n }\n }\n": types.DeleteOneRelationMetadataItemDocument,
"\n query ObjectMetadataItems {\n objects(paging: { first: 1000 }) {\n edges {\n node {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isRemote\n isActive\n isSystem\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n shortcut\n isLabelSyncedWithName\n isSearchable\n duplicateCriteria\n indexMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n name\n indexWhereClause\n indexType\n isUnique\n indexFieldMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n order\n fieldMetadataId\n }\n }\n }\n }\n }\n }\n fieldsList {\n id\n type\n name\n label\n description\n icon\n isCustom\n isActive\n isSystem\n isNullable\n isUnique\n createdAt\n updatedAt\n defaultValue\n options\n settings\n isLabelSyncedWithName\n relationDefinition {\n relationId\n direction\n sourceObjectMetadata {\n id\n nameSingular\n namePlural\n }\n sourceFieldMetadata {\n id\n name\n }\n targetObjectMetadata {\n id\n nameSingular\n namePlural\n }\n targetFieldMetadata {\n id\n name\n }\n }\n }\n }\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n": types.ObjectMetadataItemsDocument, "\n query ObjectMetadataItems {\n objects(paging: { first: 1000 }) {\n edges {\n node {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isRemote\n isActive\n isSystem\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n shortcut\n isLabelSyncedWithName\n isSearchable\n duplicateCriteria\n indexMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n name\n indexWhereClause\n indexType\n isUnique\n indexFieldMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n order\n fieldMetadataId\n }\n }\n }\n }\n }\n }\n fieldsList {\n id\n type\n name\n label\n description\n icon\n isCustom\n isActive\n isSystem\n isNullable\n isUnique\n createdAt\n updatedAt\n defaultValue\n options\n settings\n isLabelSyncedWithName\n relationDefinition {\n relationId\n direction\n sourceObjectMetadata {\n id\n nameSingular\n namePlural\n }\n sourceFieldMetadata {\n id\n name\n }\n targetObjectMetadata {\n id\n nameSingular\n namePlural\n }\n targetFieldMetadata {\n id\n name\n }\n }\n }\n }\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n": types.ObjectMetadataItemsDocument,
@ -122,11 +122,11 @@ export function graphql(source: "\n mutation UpdateOneFieldMetadataItem(\n $
/** /**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/ */
export function graphql(source: "\n mutation UpdateOneObjectMetadataItem(\n $idToUpdate: UUID!\n $updatePayload: UpdateObjectPayload!\n ) {\n updateOneObject(input: { id: $idToUpdate, update: $updatePayload }) {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isActive\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n }\n }\n"): (typeof documents)["\n mutation UpdateOneObjectMetadataItem(\n $idToUpdate: UUID!\n $updatePayload: UpdateObjectPayload!\n ) {\n updateOneObject(input: { id: $idToUpdate, update: $updatePayload }) {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isActive\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n }\n }\n"]; export function graphql(source: "\n mutation UpdateOneObjectMetadataItem(\n $idToUpdate: UUID!\n $updatePayload: UpdateObjectPayload!\n ) {\n updateOneObject(input: { id: $idToUpdate, update: $updatePayload }) {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isActive\n isSearchable\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n }\n }\n"): (typeof documents)["\n mutation UpdateOneObjectMetadataItem(\n $idToUpdate: UUID!\n $updatePayload: UpdateObjectPayload!\n ) {\n updateOneObject(input: { id: $idToUpdate, update: $updatePayload }) {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isActive\n isSearchable\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n }\n }\n"];
/** /**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/ */
export function graphql(source: "\n mutation DeleteOneObjectMetadataItem($idToDelete: UUID!) {\n deleteOneObject(input: { id: $idToDelete }) {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isActive\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n }\n }\n"): (typeof documents)["\n mutation DeleteOneObjectMetadataItem($idToDelete: UUID!) {\n deleteOneObject(input: { id: $idToDelete }) {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isActive\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n }\n }\n"]; export function graphql(source: "\n mutation DeleteOneObjectMetadataItem($idToDelete: UUID!) {\n deleteOneObject(input: { id: $idToDelete }) {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isActive\n isSearchable\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n }\n }\n"): (typeof documents)["\n mutation DeleteOneObjectMetadataItem($idToDelete: UUID!) {\n deleteOneObject(input: { id: $idToDelete }) {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isActive\n isSearchable\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n }\n }\n"];
/** /**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/ */

File diff suppressed because one or more lines are too long

View File

@ -501,7 +501,6 @@ export enum FeatureFlagKey {
IsNewRelationEnabled = 'IsNewRelationEnabled', IsNewRelationEnabled = 'IsNewRelationEnabled',
IsPermissionsEnabled = 'IsPermissionsEnabled', IsPermissionsEnabled = 'IsPermissionsEnabled',
IsPostgreSQLIntegrationEnabled = 'IsPostgreSQLIntegrationEnabled', IsPostgreSQLIntegrationEnabled = 'IsPostgreSQLIntegrationEnabled',
IsRichTextV2Enabled = 'IsRichTextV2Enabled',
IsStripeIntegrationEnabled = 'IsStripeIntegrationEnabled', IsStripeIntegrationEnabled = 'IsStripeIntegrationEnabled',
IsUniqueIndexesEnabled = 'IsUniqueIndexesEnabled', IsUniqueIndexesEnabled = 'IsUniqueIndexesEnabled',
IsWorkflowEnabled = 'IsWorkflowEnabled' IsWorkflowEnabled = 'IsWorkflowEnabled'
@ -829,6 +828,7 @@ export type Mutation = {
sendInvitations: SendInvitationsOutput; sendInvitations: SendInvitationsOutput;
signUp: SignUpOutput; signUp: SignUpOutput;
skipSyncEmailOnboardingStep: OnboardingStepSuccess; skipSyncEmailOnboardingStep: OnboardingStepSuccess;
submitFormStep: Scalars['Boolean'];
track: Analytics; track: Analytics;
updateBillingSubscription: BillingUpdateOutput; updateBillingSubscription: BillingUpdateOutput;
updateLabPublicFeatureFlag: FeatureFlag; updateLabPublicFeatureFlag: FeatureFlag;
@ -961,6 +961,7 @@ export type MutationEditSsoIdentityProviderArgs = {
export type MutationEmailPasswordResetLinkArgs = { export type MutationEmailPasswordResetLinkArgs = {
email: Scalars['String']; email: Scalars['String'];
workspaceId: Scalars['String'];
}; };
@ -1045,6 +1046,11 @@ export type MutationSignUpArgs = {
}; };
export type MutationSubmitFormStepArgs = {
input: SubmitFormStepInput;
};
export type MutationTrackArgs = { export type MutationTrackArgs = {
action: Scalars['String']; action: Scalars['String'];
payload: Scalars['JSON']; payload: Scalars['JSON'];
@ -1293,7 +1299,6 @@ export type Query = {
currentWorkspace: Workspace; currentWorkspace: Workspace;
field: Field; field: Field;
fields: FieldConnection; fields: FieldConnection;
findAvailableWorkspacesByEmail: Array<AvailableWorkspaceOutput>;
findManyServerlessFunctions: Array<ServerlessFunction>; findManyServerlessFunctions: Array<ServerlessFunction>;
findOneServerlessFunction: ServerlessFunction; findOneServerlessFunction: ServerlessFunction;
findWorkspaceFromInviteHash: Workspace; findWorkspaceFromInviteHash: Workspace;
@ -1339,11 +1344,6 @@ export type QueryCheckWorkspaceInviteHashIsValidArgs = {
}; };
export type QueryFindAvailableWorkspacesByEmailArgs = {
email: Scalars['String'];
};
export type QueryFindOneServerlessFunctionArgs = { export type QueryFindOneServerlessFunctionArgs = {
input: ServerlessFunctionIdInput; input: ServerlessFunctionIdInput;
}; };
@ -1665,6 +1665,15 @@ export type SignUpOutput = {
workspace: WorkspaceUrlsAndId; workspace: WorkspaceUrlsAndId;
}; };
export type SubmitFormStepInput = {
/** Form response in JSON format */
response: Scalars['JSON'];
/** Workflow version ID */
stepId: Scalars['String'];
/** Workflow run ID */
workflowRunId: Scalars['String'];
};
export enum SubscriptionInterval { export enum SubscriptionInterval {
Day = 'Day', Day = 'Day',
Month = 'Month', Month = 'Month',
@ -2152,6 +2161,11 @@ export type GetTimelineThreadsFromPersonIdQueryVariables = Exact<{
export type GetTimelineThreadsFromPersonIdQuery = { __typename?: 'Query', getTimelineThreadsFromPersonId: { __typename?: 'TimelineThreadsWithTotal', totalNumberOfThreads: number, timelineThreads: Array<{ __typename?: 'TimelineThread', id: any, read: boolean, visibility: MessageChannelVisibility, lastMessageReceivedAt: string, lastMessageBody: string, subject: string, numberOfMessagesInThread: number, participantCount: number, firstParticipant: { __typename?: 'TimelineThreadParticipant', personId?: any | null, workspaceMemberId?: any | null, firstName: string, lastName: string, displayName: string, avatarUrl: string, handle: string }, lastTwoParticipants: Array<{ __typename?: 'TimelineThreadParticipant', personId?: any | null, workspaceMemberId?: any | null, firstName: string, lastName: string, displayName: string, avatarUrl: string, handle: string }> }> } }; export type GetTimelineThreadsFromPersonIdQuery = { __typename?: 'Query', getTimelineThreadsFromPersonId: { __typename?: 'TimelineThreadsWithTotal', totalNumberOfThreads: number, timelineThreads: Array<{ __typename?: 'TimelineThread', id: any, read: boolean, visibility: MessageChannelVisibility, lastMessageReceivedAt: string, lastMessageBody: string, subject: string, numberOfMessagesInThread: number, participantCount: number, firstParticipant: { __typename?: 'TimelineThreadParticipant', personId?: any | null, workspaceMemberId?: any | null, firstName: string, lastName: string, displayName: string, avatarUrl: string, handle: string }, lastTwoParticipants: Array<{ __typename?: 'TimelineThreadParticipant', personId?: any | null, workspaceMemberId?: any | null, firstName: string, lastName: string, displayName: string, avatarUrl: string, handle: string }> }> } };
export type EmptyQueryVariables = Exact<{ [key: string]: never; }>;
export type EmptyQuery = { __typename: 'Query' };
export type TrackMutationVariables = Exact<{ export type TrackMutationVariables = Exact<{
action: Scalars['String']; action: Scalars['String'];
payload: Scalars['JSON']; payload: Scalars['JSON'];
@ -2193,6 +2207,7 @@ export type AuthorizeAppMutation = { __typename?: 'Mutation', authorizeApp: { __
export type EmailPasswordResetLinkMutationVariables = Exact<{ export type EmailPasswordResetLinkMutationVariables = Exact<{
email: Scalars['String']; email: Scalars['String'];
workspaceId: Scalars['String'];
}>; }>;
@ -2984,6 +2999,38 @@ export function useGetTimelineThreadsFromPersonIdLazyQuery(baseOptions?: Apollo.
export type GetTimelineThreadsFromPersonIdQueryHookResult = ReturnType<typeof useGetTimelineThreadsFromPersonIdQuery>; export type GetTimelineThreadsFromPersonIdQueryHookResult = ReturnType<typeof useGetTimelineThreadsFromPersonIdQuery>;
export type GetTimelineThreadsFromPersonIdLazyQueryHookResult = ReturnType<typeof useGetTimelineThreadsFromPersonIdLazyQuery>; export type GetTimelineThreadsFromPersonIdLazyQueryHookResult = ReturnType<typeof useGetTimelineThreadsFromPersonIdLazyQuery>;
export type GetTimelineThreadsFromPersonIdQueryResult = Apollo.QueryResult<GetTimelineThreadsFromPersonIdQuery, GetTimelineThreadsFromPersonIdQueryVariables>; export type GetTimelineThreadsFromPersonIdQueryResult = Apollo.QueryResult<GetTimelineThreadsFromPersonIdQuery, GetTimelineThreadsFromPersonIdQueryVariables>;
export const EmptyDocument = gql`
query Empty {
__typename
}
`;
/**
* __useEmptyQuery__
*
* To run a query within a React component, call `useEmptyQuery` and pass it any options that fit your needs.
* When your component renders, `useEmptyQuery` 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 } = useEmptyQuery({
* variables: {
* },
* });
*/
export function useEmptyQuery(baseOptions?: Apollo.QueryHookOptions<EmptyQuery, EmptyQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<EmptyQuery, EmptyQueryVariables>(EmptyDocument, options);
}
export function useEmptyLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<EmptyQuery, EmptyQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<EmptyQuery, EmptyQueryVariables>(EmptyDocument, options);
}
export type EmptyQueryHookResult = ReturnType<typeof useEmptyQuery>;
export type EmptyLazyQueryHookResult = ReturnType<typeof useEmptyLazyQuery>;
export type EmptyQueryResult = Apollo.QueryResult<EmptyQuery, EmptyQueryVariables>;
export const TrackDocument = gql` export const TrackDocument = gql`
mutation Track($action: String!, $payload: JSON!) { mutation Track($action: String!, $payload: JSON!) {
track(action: $action, payload: $payload) { track(action: $action, payload: $payload) {
@ -3122,8 +3169,8 @@ export type AuthorizeAppMutationHookResult = ReturnType<typeof useAuthorizeAppMu
export type AuthorizeAppMutationResult = Apollo.MutationResult<AuthorizeAppMutation>; export type AuthorizeAppMutationResult = Apollo.MutationResult<AuthorizeAppMutation>;
export type AuthorizeAppMutationOptions = Apollo.BaseMutationOptions<AuthorizeAppMutation, AuthorizeAppMutationVariables>; export type AuthorizeAppMutationOptions = Apollo.BaseMutationOptions<AuthorizeAppMutation, AuthorizeAppMutationVariables>;
export const EmailPasswordResetLinkDocument = gql` export const EmailPasswordResetLinkDocument = gql`
mutation EmailPasswordResetLink($email: String!) { mutation EmailPasswordResetLink($email: String!, $workspaceId: String!) {
emailPasswordResetLink(email: $email) { emailPasswordResetLink(email: $email, workspaceId: $workspaceId) {
success success
} }
} }
@ -3144,6 +3191,7 @@ export type EmailPasswordResetLinkMutationFn = Apollo.MutationFunction<EmailPass
* const [emailPasswordResetLinkMutation, { data, loading, error }] = useEmailPasswordResetLinkMutation({ * const [emailPasswordResetLinkMutation, { data, loading, error }] = useEmailPasswordResetLinkMutation({
* variables: { * variables: {
* email: // value for 'email' * email: // value for 'email'
* workspaceId: // value for 'workspaceId'
* }, * },
* }); * });
*/ */

View File

@ -1,8 +1,8 @@
import { gql } from '@apollo/client'; import { gql } from '@apollo/client';
export const EMAIL_PASSWORD_RESET_Link = gql` export const EMAIL_PASSWORD_RESET_LINK = gql`
mutation EmailPasswordResetLink($email: String!) { mutation EmailPasswordResetLink($email: String!, $workspaceId: String!) {
emailPasswordResetLink(email: $email) { emailPasswordResetLink(email: $email, workspaceId: $workspaceId) {
success success
} }
} }

View File

@ -97,6 +97,7 @@ export const SignInUpWorkspaceScopeFormEffect = () => {
} }
if ( if (
signInUpStep !== SignInUpStep.Email &&
isDefined(email) && isDefined(email) &&
workspaceAuthProviders.password && workspaceAuthProviders.password &&
loadingStatus === LoadingStatus.Done loadingStatus === LoadingStatus.Done

View File

@ -7,8 +7,12 @@ import { SOURCE_LOCALE } from 'twenty-shared';
import { useHandleResetPassword } from '@/auth/sign-in-up/hooks/useHandleResetPassword'; import { useHandleResetPassword } from '@/auth/sign-in-up/hooks/useHandleResetPassword';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useEmailPasswordResetLinkMutation } from '~/generated/graphql'; import {
PublicWorkspaceDataOutput,
useEmailPasswordResetLinkMutation,
} from '~/generated/graphql';
import { dynamicActivate } from '~/utils/i18n/dynamicActivate'; import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState';
// Mocks // Mocks
jest.mock('@/ui/feedback/snack-bar-manager/hooks/useSnackBar'); jest.mock('@/ui/feedback/snack-bar-manager/hooks/useSnackBar');
@ -19,7 +23,14 @@ dynamicActivate(SOURCE_LOCALE);
const renderHooks = () => { const renderHooks = () => {
const { result } = renderHook(() => useHandleResetPassword(), { const { result } = renderHook(() => useHandleResetPassword(), {
wrapper: ({ children }) => wrapper: ({ children }) =>
RecoilRoot({ children: I18nProvider({ i18n, children }) }), RecoilRoot({
initializeState: ({ set }) => {
set(workspacePublicDataState, {
id: 'workspace-id',
} as PublicWorkspaceDataOutput);
},
children: I18nProvider({ i18n, children }),
}),
}); });
return { result }; return { result };
}; };

View File

@ -4,14 +4,20 @@ import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/Snac
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { useEmailPasswordResetLinkMutation } from '~/generated/graphql'; import { useEmailPasswordResetLinkMutation } from '~/generated/graphql';
import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState';
import { useRecoilValue } from 'recoil';
import { currentUserState } from '@/auth/states/currentUserState';
export const useHandleResetPassword = () => { export const useHandleResetPassword = () => {
const { enqueueSnackBar } = useSnackBar(); const { enqueueSnackBar } = useSnackBar();
const [emailPasswordResetLink] = useEmailPasswordResetLinkMutation(); const [emailPasswordResetLink] = useEmailPasswordResetLinkMutation();
const workspacePublicData = useRecoilValue(workspacePublicDataState);
const currentUser = useRecoilValue(currentUserState);
const { t } = useLingui(); const { t } = useLingui();
const handleResetPassword = useCallback( const handleResetPassword = useCallback(
(email: string) => { (email = currentUser?.email) => {
return async () => { return async () => {
if (!email) { if (!email) {
enqueueSnackBar(t`Invalid email`, { enqueueSnackBar(t`Invalid email`, {
@ -20,9 +26,16 @@ export const useHandleResetPassword = () => {
return; return;
} }
if (!workspacePublicData?.id) {
enqueueSnackBar(t`Invalid workspace`, {
variant: SnackBarVariant.Error,
});
return;
}
try { try {
const { data } = await emailPasswordResetLink({ const { data } = await emailPasswordResetLink({
variables: { email }, variables: { email, workspaceId: workspacePublicData.id },
}); });
if (data?.emailPasswordResetLink?.success === true) { if (data?.emailPasswordResetLink?.success === true) {
@ -41,7 +54,13 @@ export const useHandleResetPassword = () => {
} }
}; };
}, },
[enqueueSnackBar, emailPasswordResetLink, t], [
currentUser?.email,
workspacePublicData?.id,
enqueueSnackBar,
t,
emailPasswordResetLink,
],
); );
return { handleResetPassword }; return { handleResetPassword };

View File

@ -1,50 +1,12 @@
import { useRecoilValue } from 'recoil';
import { Button, H2Title } from 'twenty-ui'; import { Button, H2Title } from 'twenty-ui';
import { currentUserState } from '@/auth/states/currentUserState';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { useEmailPasswordResetLinkMutation } from '~/generated/graphql'; import { useHandleResetPassword } from '@/auth/sign-in-up/hooks/useHandleResetPassword';
export const ChangePassword = () => { export const ChangePassword = () => {
const { t } = useLingui(); const { t } = useLingui();
const { enqueueSnackBar } = useSnackBar(); const { handleResetPassword } = useHandleResetPassword();
const currentUser = useRecoilValue(currentUserState);
const [emailPasswordResetLink] = useEmailPasswordResetLinkMutation();
const handlePasswordResetClick = async () => {
if (!currentUser?.email) {
enqueueSnackBar(t`Invalid email`, {
variant: SnackBarVariant.Error,
});
return;
}
try {
const { data } = await emailPasswordResetLink({
variables: {
email: currentUser.email,
},
});
if (data?.emailPasswordResetLink?.success === true) {
enqueueSnackBar(t`Password reset link has been sent to the email`, {
variant: SnackBarVariant.Success,
});
} else {
enqueueSnackBar(t`There was an issue`, {
variant: SnackBarVariant.Error,
});
}
} catch (error) {
enqueueSnackBar((error as Error).message, {
variant: SnackBarVariant.Error,
});
}
};
return ( return (
<> <>
@ -53,7 +15,7 @@ export const ChangePassword = () => {
description={t`Receive an email containing password update link`} description={t`Receive an email containing password update link`}
/> />
<Button <Button
onClick={handlePasswordResetClick} onClick={handleResetPassword()}
variant="secondary" variant="secondary"
title={t`Change Password`} title={t`Change Password`}
/> />

View File

@ -24,7 +24,6 @@ import {
AuthException, AuthException,
AuthExceptionCode, AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception'; } from 'src/engine/core-modules/auth/auth.exception';
import { AvailableWorkspaceOutput } from 'src/engine/core-modules/auth/dto/available-workspaces.output';
import { GetAuthorizationUrlForSSOInput } from 'src/engine/core-modules/auth/dto/get-authorization-url-for-sso.input'; import { GetAuthorizationUrlForSSOInput } from 'src/engine/core-modules/auth/dto/get-authorization-url-for-sso.input';
import { GetAuthorizationUrlForSSOOutput } from 'src/engine/core-modules/auth/dto/get-authorization-url-for-sso.output'; import { GetAuthorizationUrlForSSOOutput } from 'src/engine/core-modules/auth/dto/get-authorization-url-for-sso.output';
import { GetLoginTokenFromEmailVerificationTokenInput } from 'src/engine/core-modules/auth/dto/get-login-token-from-email-verification-token.input'; import { GetLoginTokenFromEmailVerificationTokenInput } from 'src/engine/core-modules/auth/dto/get-login-token-from-email-verification-token.input';
@ -366,6 +365,7 @@ export class AuthResolver {
const resetToken = const resetToken =
await this.resetPasswordService.generatePasswordResetToken( await this.resetPasswordService.generatePasswordResetToken(
emailPasswordResetInput.email, emailPasswordResetInput.email,
emailPasswordResetInput.workspaceId,
); );
return await this.resetPasswordService.sendEmailPasswordResetLink( return await this.resetPasswordService.sendEmailPasswordResetLink(
@ -403,11 +403,4 @@ export class AuthResolver {
args.passwordResetToken, args.passwordResetToken,
); );
} }
@Query(() => [AvailableWorkspaceOutput])
async findAvailableWorkspacesByEmail(
@Args('email') email: string,
): Promise<AvailableWorkspaceOutput[]> {
return this.userWorkspaceService.findAvailableWorkspacesByEmail(email);
}
} }

View File

@ -1,6 +1,6 @@
import { ArgsType, Field } from '@nestjs/graphql'; import { ArgsType, Field } from '@nestjs/graphql';
import { IsEmail, IsNotEmpty } from 'class-validator'; import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
@ArgsType() @ArgsType()
export class EmailPasswordResetLinkInput { export class EmailPasswordResetLinkInput {
@ -8,4 +8,9 @@ export class EmailPasswordResetLinkInput {
@IsNotEmpty() @IsNotEmpty()
@IsEmail() @IsEmail()
email: string; email: string;
@Field(() => String)
@IsNotEmpty()
@IsString()
workspaceId: string;
} }

View File

@ -37,4 +37,7 @@ export class PasswordResetToken {
@Field(() => Date) @Field(() => Date)
passwordResetTokenExpiresAt: Date; passwordResetTokenExpiresAt: Date;
@Field(() => String)
workspaceId: string;
} }

View File

@ -20,9 +20,11 @@ import { ResetPasswordService } from './reset-password.service';
describe('ResetPasswordService', () => { describe('ResetPasswordService', () => {
let service: ResetPasswordService; let service: ResetPasswordService;
let userRepository: Repository<User>; let userRepository: Repository<User>;
let workspaceRepository: Repository<Workspace>;
let appTokenRepository: Repository<AppToken>; let appTokenRepository: Repository<AppToken>;
let emailService: EmailService; let emailService: EmailService;
let environmentService: EnvironmentService; let environmentService: EnvironmentService;
let domainManagerService: DomainManagerService;
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
@ -32,6 +34,10 @@ describe('ResetPasswordService', () => {
provide: getRepositoryToken(User, 'core'), provide: getRepositoryToken(User, 'core'),
useClass: Repository, useClass: Repository,
}, },
{
provide: getRepositoryToken(Workspace, 'core'),
useClass: Repository,
},
{ {
provide: getRepositoryToken(AppToken, 'core'), provide: getRepositoryToken(AppToken, 'core'),
useClass: Repository, useClass: Repository,
@ -52,6 +58,7 @@ describe('ResetPasswordService', () => {
getBaseUrl: jest getBaseUrl: jest
.fn() .fn()
.mockResolvedValue(new URL('http://localhost:3001')), .mockResolvedValue(new URL('http://localhost:3001')),
buildWorkspaceURL: jest.fn(),
}, },
}, },
{ {
@ -67,11 +74,16 @@ describe('ResetPasswordService', () => {
userRepository = module.get<Repository<User>>( userRepository = module.get<Repository<User>>(
getRepositoryToken(User, 'core'), getRepositoryToken(User, 'core'),
); );
workspaceRepository = module.get<Repository<Workspace>>(
getRepositoryToken(Workspace, 'core'),
);
appTokenRepository = module.get<Repository<AppToken>>( appTokenRepository = module.get<Repository<AppToken>>(
getRepositoryToken(AppToken, 'core'), getRepositoryToken(AppToken, 'core'),
); );
emailService = module.get<EmailService>(EmailService); emailService = module.get<EmailService>(EmailService);
environmentService = module.get<EnvironmentService>(EnvironmentService); environmentService = module.get<EnvironmentService>(EnvironmentService);
domainManagerService =
module.get<DomainManagerService>(DomainManagerService);
}); });
it('should be defined', () => { it('should be defined', () => {
@ -89,8 +101,10 @@ describe('ResetPasswordService', () => {
jest.spyOn(appTokenRepository, 'save').mockResolvedValue({} as AppToken); jest.spyOn(appTokenRepository, 'save').mockResolvedValue({} as AppToken);
jest.spyOn(environmentService, 'get').mockReturnValue('1h'); jest.spyOn(environmentService, 'get').mockReturnValue('1h');
const result = const result = await service.generatePasswordResetToken(
await service.generatePasswordResetToken('test@example.com'); 'test@example.com',
'workspace-id',
);
expect(result.passwordResetToken).toBeDefined(); expect(result.passwordResetToken).toBeDefined();
expect(result.passwordResetTokenExpiresAt).toBeDefined(); expect(result.passwordResetTokenExpiresAt).toBeDefined();
@ -106,7 +120,10 @@ describe('ResetPasswordService', () => {
jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(null); jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(null);
await expect( await expect(
service.generatePasswordResetToken('nonexistent@example.com'), service.generatePasswordResetToken(
'nonexistent@example.com',
'workspace-id',
),
).rejects.toThrow(AuthException); ).rejects.toThrow(AuthException);
}); });
@ -115,6 +132,7 @@ describe('ResetPasswordService', () => {
const mockExistingToken = { const mockExistingToken = {
userId: '1', userId: '1',
type: AppTokenType.PasswordResetToken, type: AppTokenType.PasswordResetToken,
workspaceId: 'workspace-id',
expiresAt: addMilliseconds(new Date(), 3600000), expiresAt: addMilliseconds(new Date(), 3600000),
}; };
@ -126,7 +144,7 @@ describe('ResetPasswordService', () => {
.mockResolvedValue(mockExistingToken as AppToken); .mockResolvedValue(mockExistingToken as AppToken);
await expect( await expect(
service.generatePasswordResetToken('test@example.com'), service.generatePasswordResetToken('test@example.com', 'workspace-id'),
).rejects.toThrow(AuthException); ).rejects.toThrow(AuthException);
}); });
}); });
@ -135,6 +153,7 @@ describe('ResetPasswordService', () => {
it('should send a password reset email', async () => { it('should send a password reset email', async () => {
const mockUser = { id: '1', email: 'test@example.com' }; const mockUser = { id: '1', email: 'test@example.com' };
const mockToken = { const mockToken = {
workspaceId: 'workspace-id',
passwordResetToken: 'token123', passwordResetToken: 'token123',
passwordResetTokenExpiresAt: new Date(), passwordResetTokenExpiresAt: new Date(),
}; };
@ -142,9 +161,19 @@ describe('ResetPasswordService', () => {
jest jest
.spyOn(userRepository, 'findOneBy') .spyOn(userRepository, 'findOneBy')
.mockResolvedValue(mockUser as User); .mockResolvedValue(mockUser as User);
jest
.spyOn(workspaceRepository, 'findOneBy')
.mockResolvedValue({ id: 'workspace-id' } as Workspace);
jest jest
.spyOn(environmentService, 'get') .spyOn(environmentService, 'get')
.mockReturnValue('http://localhost:3000'); .mockReturnValue('http://localhost:3000');
jest
.spyOn(domainManagerService, 'buildWorkspaceURL')
.mockReturnValue(
new URL(
'https://subdomain.localhost.com:3000/reset-password/passwordResetToken',
),
);
const result = await service.sendEmailPasswordResetLink( const result = await service.sendEmailPasswordResetLink(
mockToken, mockToken,

View File

@ -28,6 +28,8 @@ import { DomainManagerService } from 'src/engine/core-modules/domain-manager/ser
import { EmailService } from 'src/engine/core-modules/email/email.service'; import { EmailService } from 'src/engine/core-modules/email/email.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { User } from 'src/engine/core-modules/user/user.entity'; import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
@Injectable() @Injectable()
export class ResetPasswordService { export class ResetPasswordService {
@ -36,12 +38,17 @@ export class ResetPasswordService {
private readonly domainManagerService: DomainManagerService, private readonly domainManagerService: DomainManagerService,
@InjectRepository(User, 'core') @InjectRepository(User, 'core')
private readonly userRepository: Repository<User>, private readonly userRepository: Repository<User>,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(AppToken, 'core') @InjectRepository(AppToken, 'core')
private readonly appTokenRepository: Repository<AppToken>, private readonly appTokenRepository: Repository<AppToken>,
private readonly emailService: EmailService, private readonly emailService: EmailService,
) {} ) {}
async generatePasswordResetToken(email: string): Promise<PasswordResetToken> { async generatePasswordResetToken(
email: string,
workspaceId: string,
): Promise<PasswordResetToken> {
const user = await this.userRepository.findOneBy({ const user = await this.userRepository.findOneBy({
email, email,
}); });
@ -95,12 +102,14 @@ export class ResetPasswordService {
await this.appTokenRepository.save({ await this.appTokenRepository.save({
userId: user.id, userId: user.id,
workspaceId: workspaceId,
value: hashedResetToken, value: hashedResetToken,
expiresAt, expiresAt,
type: AppTokenType.PasswordResetToken, type: AppTokenType.PasswordResetToken,
}); });
return { return {
workspaceId,
passwordResetToken: plainResetToken, passwordResetToken: plainResetToken,
passwordResetTokenExpiresAt: expiresAt, passwordResetTokenExpiresAt: expiresAt,
}; };
@ -122,12 +131,19 @@ export class ResetPasswordService {
); );
} }
const frontBaseURL = this.domainManagerService.getBaseUrl(); const workspace = await this.workspaceRepository.findOneBy({
id: resetToken.workspaceId,
});
frontBaseURL.pathname = `/reset-password/${resetToken.passwordResetToken}`; workspaceValidator.assertIsDefinedOrThrow(workspace);
const link = this.domainManagerService.buildWorkspaceURL({
workspace,
pathname: `/reset-password/${resetToken.passwordResetToken}`,
});
const emailData = { const emailData = {
link: frontBaseURL.toString(), link: link.toString(),
duration: ms( duration: ms(
differenceInMilliseconds( differenceInMilliseconds(
resetToken.passwordResetTokenExpiresAt, resetToken.passwordResetTokenExpiresAt,