GH-3652 Add forgot password on sign-in page (#3789)

* Remove auth guard from password reset email endpoint

* Add arg for GQL mutation and update its usage

* Add forgot password button on sign-in page

* Generate automated graphql queries

* Move utils to dedicated hook

* Remove useless hook function

* Split simple hook methods

* Split workspace hook

* Split signInWithGoogle hook

* Split useSignInUpForm

* Fix error in logs

* Add Link Button UI Component

* Add storybook doc

---------

Co-authored-by: martmull <martmull@hotmail.fr>
This commit is contained in:
Deepak Kumar 2024-02-09 22:07:44 +05:30 committed by GitHub
parent 917fc5bd4d
commit 3cbf958a1c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 399 additions and 116 deletions

View File

@ -24,6 +24,11 @@ export type Scalars = {
Upload: { input: any; output: any; } Upload: { input: any; output: any; }
}; };
export type ApiKeyToken = {
__typename?: 'ApiKeyToken';
token: Scalars['String']['output'];
};
export type AuthProviders = { export type AuthProviders = {
__typename?: 'AuthProviders'; __typename?: 'AuthProviders';
google: Scalars['Boolean']['output']; google: Scalars['Boolean']['output'];
@ -43,6 +48,11 @@ export type AuthTokenPair = {
refreshToken: AuthToken; refreshToken: AuthToken;
}; };
export type AuthTokens = {
__typename?: 'AuthTokens';
tokens: AuthTokenPair;
};
export type Billing = { export type Billing = {
__typename?: 'Billing'; __typename?: 'Billing';
billingUrl: Scalars['String']['output']; billingUrl: Scalars['String']['output'];
@ -136,6 +146,12 @@ export type DeleteOneRelationInput = {
id: Scalars['ID']['input']; id: Scalars['ID']['input'];
}; };
export type EmailPasswordResetLink = {
__typename?: 'EmailPasswordResetLink';
/** Boolean that confirms query was dispatched */
success: Scalars['Boolean']['output'];
};
export type FieldConnection = { export type FieldConnection = {
__typename?: 'FieldConnection'; __typename?: 'FieldConnection';
/** Array of edges. */ /** Array of edges. */
@ -214,8 +230,20 @@ export type IdFilterComparison = {
notLike?: InputMaybe<Scalars['ID']['input']>; notLike?: InputMaybe<Scalars['ID']['input']>;
}; };
export type InvalidatePassword = {
__typename?: 'InvalidatePassword';
/** Boolean that confirms query was dispatched */
success: Scalars['Boolean']['output'];
};
export type LoginToken = {
__typename?: 'LoginToken';
loginToken: AuthToken;
};
export type Mutation = { export type Mutation = {
__typename?: 'Mutation'; __typename?: 'Mutation';
challenge: LoginToken;
createOneField: Field; createOneField: Field;
createOneObject: Object; createOneObject: Object;
createOneRelation: Relation; createOneRelation: Relation;
@ -223,11 +251,25 @@ export type Mutation = {
deleteOneObject: Object; deleteOneObject: Object;
deleteOneRelation: RelationDeleteResponse; deleteOneRelation: RelationDeleteResponse;
deleteUser: User; deleteUser: User;
emailPasswordResetLink: EmailPasswordResetLink;
generateApiKeyToken: ApiKeyToken;
generateTransientToken: TransientToken;
impersonate: Verify;
renewToken: AuthTokens;
signUp: LoginToken;
updateOneField: Field; updateOneField: Field;
updateOneObject: Object; updateOneObject: Object;
updatePasswordViaResetToken: InvalidatePassword;
uploadFile: Scalars['String']['output']; uploadFile: Scalars['String']['output'];
uploadImage: Scalars['String']['output']; uploadImage: Scalars['String']['output'];
uploadProfilePicture: Scalars['String']['output']; uploadProfilePicture: Scalars['String']['output'];
verify: Verify;
};
export type MutationChallengeArgs = {
email: Scalars['String']['input'];
password: Scalars['String']['input'];
}; };
@ -261,6 +303,34 @@ export type MutationDeleteOneRelationArgs = {
}; };
export type MutationEmailPasswordResetLinkArgs = {
email: Scalars['String']['input'];
};
export type MutationGenerateApiKeyTokenArgs = {
apiKeyId: Scalars['String']['input'];
expiresAt: Scalars['String']['input'];
};
export type MutationImpersonateArgs = {
userId: Scalars['String']['input'];
};
export type MutationRenewTokenArgs = {
refreshToken: Scalars['String']['input'];
};
export type MutationSignUpArgs = {
email: Scalars['String']['input'];
password: Scalars['String']['input'];
workspaceInviteHash?: InputMaybe<Scalars['String']['input']>;
};
export type MutationUpdateOneFieldArgs = { export type MutationUpdateOneFieldArgs = {
input: UpdateOneFieldMetadataInput; input: UpdateOneFieldMetadataInput;
}; };
@ -271,6 +341,12 @@ export type MutationUpdateOneObjectArgs = {
}; };
export type MutationUpdatePasswordViaResetTokenArgs = {
newPassword: Scalars['String']['input'];
passwordResetToken: Scalars['String']['input'];
};
export type MutationUploadFileArgs = { export type MutationUploadFileArgs = {
file: Scalars['Upload']['input']; file: Scalars['Upload']['input'];
fileFolder?: InputMaybe<FileFolder>; fileFolder?: InputMaybe<FileFolder>;
@ -287,6 +363,11 @@ export type MutationUploadProfilePictureArgs = {
file: Scalars['Upload']['input']; file: Scalars['Upload']['input'];
}; };
export type MutationVerifyArgs = {
loginToken: Scalars['String']['input'];
};
export type ObjectConnection = { export type ObjectConnection = {
__typename?: 'ObjectConnection'; __typename?: 'ObjectConnection';
/** Array of edges. */ /** Array of edges. */
@ -321,13 +402,27 @@ export type PageInfo = {
export type Query = { export type Query = {
__typename?: 'Query'; __typename?: 'Query';
checkUserExists: UserExists;
checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid;
currentUser: User; currentUser: User;
field: Field; field: Field;
fields: FieldConnection; fields: FieldConnection;
findWorkspaceFromInviteHash: Workspace;
object: Object; object: Object;
objects: ObjectConnection; objects: ObjectConnection;
relation: Relation; relation: Relation;
relations: RelationConnection; relations: RelationConnection;
validatePasswordResetToken: ValidatePasswordResetToken;
};
export type QueryCheckUserExistsArgs = {
email: Scalars['String']['input'];
};
export type QueryCheckWorkspaceInviteHashIsValidArgs = {
inviteHash: Scalars['String']['input'];
}; };
@ -342,6 +437,11 @@ export type QueryFieldsArgs = {
}; };
export type QueryFindWorkspaceFromInviteHashArgs = {
inviteHash: Scalars['String']['input'];
};
export type QueryObjectArgs = { export type QueryObjectArgs = {
id: Scalars['ID']['input']; id: Scalars['ID']['input'];
}; };
@ -362,6 +462,11 @@ export type QueryRelationsArgs = {
paging?: CursorPaging; paging?: CursorPaging;
}; };
export type QueryValidatePasswordResetTokenArgs = {
passwordResetToken: Scalars['String']['input'];
};
export type RefreshToken = { export type RefreshToken = {
__typename?: 'RefreshToken'; __typename?: 'RefreshToken';
createdAt: Scalars['DateTime']['output']; createdAt: Scalars['DateTime']['output'];
@ -424,6 +529,19 @@ export type Telemetry = {
enabled: Scalars['Boolean']['output']; enabled: Scalars['Boolean']['output'];
}; };
export type TimelineThread = {
__typename?: 'TimelineThread';
firstParticipant: TimelineThreadParticipant;
id: Scalars['ID']['output'];
lastMessageBody: Scalars['String']['output'];
lastMessageReceivedAt: Scalars['DateTime']['output'];
lastTwoParticipants: Array<TimelineThreadParticipant>;
numberOfMessagesInThread: Scalars['Float']['output'];
participantCount: Scalars['Float']['output'];
read: Scalars['Boolean']['output'];
subject: Scalars['String']['output'];
};
export type TimelineThreadParticipant = { export type TimelineThreadParticipant = {
__typename?: 'TimelineThreadParticipant'; __typename?: 'TimelineThreadParticipant';
avatarUrl: Scalars['String']['output']; avatarUrl: Scalars['String']['output'];
@ -435,6 +553,11 @@ export type TimelineThreadParticipant = {
workspaceMemberId?: Maybe<Scalars['ID']['output']>; workspaceMemberId?: Maybe<Scalars['ID']['output']>;
}; };
export type TransientToken = {
__typename?: 'TransientToken';
transientToken: AuthToken;
};
export type UpdateFieldInput = { export type UpdateFieldInput = {
defaultValue?: InputMaybe<Scalars['JSON']['input']>; defaultValue?: InputMaybe<Scalars['JSON']['input']>;
description?: InputMaybe<Scalars['String']['input']>; description?: InputMaybe<Scalars['String']['input']>;
@ -502,6 +625,23 @@ export type UserEdge = {
node: User; node: User;
}; };
export type UserExists = {
__typename?: 'UserExists';
exists: Scalars['Boolean']['output'];
};
export type ValidatePasswordResetToken = {
__typename?: 'ValidatePasswordResetToken';
email: Scalars['String']['output'];
id: Scalars['String']['output'];
};
export type Verify = {
__typename?: 'Verify';
tokens: AuthTokenPair;
user: User;
};
export type Workspace = { export type Workspace = {
__typename?: 'Workspace'; __typename?: 'Workspace';
allowImpersonation: Scalars['Boolean']['output']; allowImpersonation: Scalars['Boolean']['output'];
@ -524,6 +664,11 @@ export type WorkspaceEdge = {
node: Workspace; node: Workspace;
}; };
export type WorkspaceInviteHashValid = {
__typename?: 'WorkspaceInviteHashValid';
isValid: Scalars['Boolean']['output'];
};
export type WorkspaceMember = { export type WorkspaceMember = {
__typename?: 'WorkspaceMember'; __typename?: 'WorkspaceMember';
avatarUrl?: Maybe<Scalars['String']['output']>; avatarUrl?: Maybe<Scalars['String']['output']>;

View File

@ -265,6 +265,11 @@ export type MutationDeleteOneObjectArgs = {
}; };
export type MutationEmailPasswordResetLinkArgs = {
email: Scalars['String'];
};
export type MutationGenerateApiKeyTokenArgs = { export type MutationGenerateApiKeyTokenArgs = {
apiKeyId: Scalars['String']; apiKeyId: Scalars['String'];
expiresAt: Scalars['String']; expiresAt: Scalars['String'];
@ -752,7 +757,9 @@ export type ChallengeMutationVariables = Exact<{
export type ChallengeMutation = { __typename?: 'Mutation', challenge: { __typename?: 'LoginToken', loginToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } }; export type ChallengeMutation = { __typename?: 'Mutation', challenge: { __typename?: 'LoginToken', loginToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } };
export type EmailPasswordResetLinkMutationVariables = Exact<{ [key: string]: never; }>; export type EmailPasswordResetLinkMutationVariables = Exact<{
email: Scalars['String'];
}>;
export type EmailPasswordResetLinkMutation = { __typename?: 'Mutation', emailPasswordResetLink: { __typename?: 'EmailPasswordResetLink', success: boolean } }; export type EmailPasswordResetLinkMutation = { __typename?: 'Mutation', emailPasswordResetLink: { __typename?: 'EmailPasswordResetLink', success: boolean } };
@ -1143,8 +1150,8 @@ export type ChallengeMutationHookResult = ReturnType<typeof useChallengeMutation
export type ChallengeMutationResult = Apollo.MutationResult<ChallengeMutation>; export type ChallengeMutationResult = Apollo.MutationResult<ChallengeMutation>;
export type ChallengeMutationOptions = Apollo.BaseMutationOptions<ChallengeMutation, ChallengeMutationVariables>; export type ChallengeMutationOptions = Apollo.BaseMutationOptions<ChallengeMutation, ChallengeMutationVariables>;
export const EmailPasswordResetLinkDocument = gql` export const EmailPasswordResetLinkDocument = gql`
mutation EmailPasswordResetLink { mutation EmailPasswordResetLink($email: String!) {
emailPasswordResetLink { emailPasswordResetLink(email: $email) {
success success
} }
} }
@ -1164,6 +1171,7 @@ export type EmailPasswordResetLinkMutationFn = Apollo.MutationFunction<EmailPass
* @example * @example
* const [emailPasswordResetLinkMutation, { data, loading, error }] = useEmailPasswordResetLinkMutation({ * const [emailPasswordResetLinkMutation, { data, loading, error }] = useEmailPasswordResetLinkMutation({
* variables: { * variables: {
* email: // value for 'email'
* }, * },
* }); * });
*/ */

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 { mutation EmailPasswordResetLink($email: String!) {
emailPasswordResetLink { emailPasswordResetLink(email: $email) {
success success
} }
} }

View File

@ -1,13 +1,20 @@
import { useMemo } from 'react'; import { useMemo, useState } from 'react';
import { Controller } from 'react-hook-form'; import { Controller } from 'react-hook-form';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { useRecoilState } from 'recoil';
import { useHandleResetPassword } from '@/auth/sign-in-up/hooks/useHandleResetPassword.ts';
import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm.ts';
import { useSignInWithGoogle } from '@/auth/sign-in-up/hooks/useSignInWithGoogle.ts';
import { useWorkspaceFromInviteHash } from '@/auth/sign-in-up/hooks/useWorkspaceFromInviteHash.ts';
import { authProvidersState } from '@/client-config/states/authProvidersState.ts';
import { IconGoogle } from '@/ui/display/icon/components/IconGoogle'; import { IconGoogle } from '@/ui/display/icon/components/IconGoogle';
import { Loader } from '@/ui/feedback/loader/components/Loader'; import { Loader } from '@/ui/feedback/loader/components/Loader';
import { MainButton } from '@/ui/input/button/components/MainButton'; import { MainButton } from '@/ui/input/button/components/MainButton';
import { TextInput } from '@/ui/input/components/TextInput'; import { TextInput } from '@/ui/input/components/TextInput';
import { ActionLink } from '@/ui/navigation/link/components/ActionLink.tsx';
import { AnimatedEaseIn } from '@/ui/utilities/animation/components/AnimatedEaseIn'; import { AnimatedEaseIn } from '@/ui/utilities/animation/components/AnimatedEaseIn';
import { Logo } from '../../components/Logo'; import { Logo } from '../../components/Logo';
@ -43,24 +50,20 @@ const StyledInputContainer = styled.div`
`; `;
export const SignInUpForm = () => { export const SignInUpForm = () => {
const [authProviders] = useRecoilState(authProvidersState);
const [showErrors, setShowErrors] = useState(false);
const { handleResetPassword } = useHandleResetPassword();
const workspace = useWorkspaceFromInviteHash();
const { signInWithGoogle } = useSignInWithGoogle();
const { form } = useSignInUpForm();
const { const {
authProviders,
signInWithGoogle,
signInUpStep, signInUpStep,
signInUpMode, signInUpMode,
showErrors,
setShowErrors,
continueWithCredentials, continueWithCredentials,
continueWithEmail, continueWithEmail,
submitCredentials, submitCredentials,
form: { } = useSignInUp(form);
control,
watch,
handleSubmit,
formState: { isSubmitting },
},
workspace,
} = useSignInUp();
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') { if (event.key === 'Enter') {
@ -72,7 +75,7 @@ export const SignInUpForm = () => {
continueWithCredentials(); continueWithCredentials();
} else if (signInUpStep === SignInUpStep.Password) { } else if (signInUpStep === SignInUpStep.Password) {
setShowErrors(true); setShowErrors(true);
handleSubmit(submitCredentials)(); form.handleSubmit(submitCredentials)();
} }
} }
}; };
@ -88,10 +91,10 @@ export const SignInUpForm = () => {
return signInUpMode === SignInUpMode.SignIn return signInUpMode === SignInUpMode.SignIn
? 'Sign in' ? 'Sign in'
: isSubmitting : form.formState.isSubmitting
? 'Creating workspace' ? 'Creating workspace'
: 'Sign up'; : 'Sign up';
}, [signInUpMode, signInUpStep, isSubmitting]); }, [signInUpMode, signInUpStep, form.formState.isSubmitting]);
const title = useMemo(() => { const title = useMemo(() => {
if (signInUpMode === SignInUpMode.Invite) { if (signInUpMode === SignInUpMode.Invite) {
@ -141,7 +144,7 @@ export const SignInUpForm = () => {
> >
<Controller <Controller
name="email" name="email"
control={control} control={form.control}
render={({ render={({
field: { onChange, onBlur, value }, field: { onChange, onBlur, value },
fieldState: { error }, fieldState: { error },
@ -180,7 +183,7 @@ export const SignInUpForm = () => {
> >
<Controller <Controller
name="password" name="password"
control={control} control={form.control}
render={({ render={({
field: { onChange, onBlur, value }, field: { onChange, onBlur, value },
fieldState: { error }, fieldState: { error },
@ -218,24 +221,32 @@ export const SignInUpForm = () => {
return; return;
} }
setShowErrors(true); setShowErrors(true);
handleSubmit(submitCredentials)(); form.handleSubmit(submitCredentials)();
}} }}
Icon={() => isSubmitting && <Loader />} Icon={() => form.formState.isSubmitting && <Loader />}
disabled={ disabled={
SignInUpStep.Init SignInUpStep.Init
? false ? false
: signInUpStep === SignInUpStep.Email : signInUpStep === SignInUpStep.Email
? !watch('email') ? !form.watch('email')
: !watch('email') || !watch('password') || isSubmitting : !form.watch('email') ||
!form.watch('password') ||
form.formState.isSubmitting
} }
fullWidth fullWidth
/> />
</StyledForm> </StyledForm>
</StyledContentContainer> </StyledContentContainer>
{signInUpStep === SignInUpStep.Password ? (
<ActionLink onClick={handleResetPassword(form.getValues('email'))}>
Forgot your password?
</ActionLink>
) : (
<StyledFooterNote> <StyledFooterNote>
By using Twenty, you agree to the Terms of Service and Data Processing By using Twenty, you agree to the Terms of Service and Data Processing
Agreement. Agreement.
</StyledFooterNote> </StyledFooterNote>
)}
</> </>
); );
}; };

View File

@ -0,0 +1,45 @@
import { useCallback } from 'react';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar.tsx';
import { useEmailPasswordResetLinkMutation } from '~/generated/graphql.tsx';
export const useHandleResetPassword = () => {
const { enqueueSnackBar } = useSnackBar();
const [emailPasswordResetLink] = useEmailPasswordResetLinkMutation();
const handleResetPassword = useCallback(
(email: string) => {
return async () => {
if (!email) {
enqueueSnackBar('Invalid email', {
variant: 'error',
});
return;
}
try {
const { data } = await emailPasswordResetLink({
variables: { email },
});
if (data?.emailPasswordResetLink?.success) {
enqueueSnackBar('Password reset link has been sent to the email', {
variant: 'success',
});
} else {
enqueueSnackBar('There was some issue', {
variant: 'error',
});
}
} catch (error) {
enqueueSnackBar((error as Error).message, {
variant: 'error',
});
}
};
},
[enqueueSnackBar, emailPasswordResetLink],
);
return { handleResetPassword };
};

View File

@ -1,22 +1,17 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useState } from 'react';
import { SubmitHandler, useForm } from 'react-hook-form'; import { SubmitHandler, UseFormReturn } from 'react-hook-form';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { zodResolver } from '@hookform/resolvers/zod'; import { useRecoilValue } from 'recoil';
import { useRecoilState, useRecoilValue } from 'recoil';
import { z } from 'zod';
import { authProvidersState } from '@/client-config/states/authProvidersState'; import { Form } from '@/auth/sign-in-up/hooks/useSignInUpForm.ts';
import { billingState } from '@/client-config/states/billingState'; import { billingState } from '@/client-config/states/billingState';
import { isSignInPrefilledState } from '@/client-config/states/isSignInPrefilledState';
import { AppPath } from '@/types/AppPath'; import { AppPath } from '@/types/AppPath';
import { PageHotkeyScope } from '@/types/PageHotkeyScope'; import { PageHotkeyScope } from '@/types/PageHotkeyScope';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useGetWorkspaceFromInviteHashQuery } from '~/generated/graphql';
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation'; import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation';
import { useAuth } from '../../hooks/useAuth'; import { useAuth } from '../../hooks/useAuth';
import { PASSWORD_REGEX } from '../../utils/passwordRegex';
export enum SignInUpMode { export enum SignInUpMode {
SignIn = 'sign-in', SignIn = 'sign-in',
@ -30,27 +25,11 @@ export enum SignInUpStep {
Password = 'password', Password = 'password',
} }
const validationSchema = z export const useSignInUp = (form: UseFormReturn<Form>) => {
.object({
exist: z.boolean(),
email: z.string().trim().email('Email must be a valid email'),
password: z
.string()
.regex(PASSWORD_REGEX, 'Password must contain at least 8 characters'),
})
.required();
type Form = z.infer<typeof validationSchema>;
export const useSignInUp = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { enqueueSnackBar } = useSnackBar(); const { enqueueSnackBar } = useSnackBar();
const isMatchingLocation = useIsMatchingLocation(); const isMatchingLocation = useIsMatchingLocation();
const [authProviders] = useRecoilState(authProvidersState);
const isSignInPrefilled = useRecoilValue(isSignInPrefilledState);
const billing = useRecoilValue(billingState); const billing = useRecoilValue(billingState);
const workspaceInviteHash = useParams().workspaceInviteHash; const workspaceInviteHash = useParams().workspaceInviteHash;
const [signInUpStep, setSignInUpStep] = useState<SignInUpStep>( const [signInUpStep, setSignInUpStep] = useState<SignInUpStep>(
SignInUpStep.Init, SignInUpStep.Init,
@ -64,31 +43,9 @@ export const useSignInUp = () => {
? SignInUpMode.SignIn ? SignInUpMode.SignIn
: SignInUpMode.SignUp; : SignInUpMode.SignUp;
}); });
const [showErrors, setShowErrors] = useState(false);
const { data: workspaceFromInviteHash } = useGetWorkspaceFromInviteHashQuery({
variables: { inviteHash: workspaceInviteHash || '' },
});
const form = useForm<Form>({
mode: 'onChange',
defaultValues: {
exist: false,
},
resolver: zodResolver(validationSchema),
});
useEffect(() => {
if (isSignInPrefilled) {
form.setValue('email', 'tim@apple.dev');
form.setValue('password', 'Applecar2025');
}
}, [form, isSignInPrefilled]);
const { const {
signInWithCredentials, signInWithCredentials,
signUpWithCredentials, signUpWithCredentials,
signInWithGoogle,
checkUserExists: { checkUserExistsQuery }, checkUserExists: { checkUserExistsQuery },
} = useAuth(); } = useAuth();
@ -169,10 +126,6 @@ export const useSignInUp = () => {
], ],
); );
const goBackToEmailStep = useCallback(() => {
setSignInUpStep(SignInUpStep.Email);
}, [setSignInUpStep]);
useScopedHotkeys( useScopedHotkeys(
'enter', 'enter',
() => { () => {
@ -193,17 +146,10 @@ export const useSignInUp = () => {
); );
return { return {
authProviders,
signInWithGoogle: () => signInWithGoogle(workspaceInviteHash),
signInUpStep, signInUpStep,
signInUpMode, signInUpMode,
showErrors,
setShowErrors,
continueWithCredentials, continueWithCredentials,
continueWithEmail, continueWithEmail,
goBackToEmailStep,
submitCredentials, submitCredentials,
form,
workspace: workspaceFromInviteHash?.findWorkspaceFromInviteHash,
}; };
}; };

View File

@ -0,0 +1,38 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRecoilValue } from 'recoil';
import { z } from 'zod';
import { PASSWORD_REGEX } from '@/auth/utils/passwordRegex.ts';
import { isSignInPrefilledState } from '@/client-config/states/isSignInPrefilledState.ts';
const validationSchema = z
.object({
exist: z.boolean(),
email: z.string().trim().email('Email must be a valid email'),
password: z
.string()
.regex(PASSWORD_REGEX, 'Password must contain at least 8 characters'),
})
.required();
export type Form = z.infer<typeof validationSchema>;
export const useSignInUpForm = () => {
const isSignInPrefilled = useRecoilValue(isSignInPrefilledState);
const form = useForm<Form>({
mode: 'onChange',
defaultValues: {
exist: false,
},
resolver: zodResolver(validationSchema),
});
useEffect(() => {
if (isSignInPrefilled) {
form.setValue('email', 'tim@apple.dev');
form.setValue('password', 'Applecar2025');
}
}, [form, isSignInPrefilled]);
return { form: form };
};

View File

@ -0,0 +1,9 @@
import { useParams } from 'react-router-dom';
import { useAuth } from '@/auth/hooks/useAuth.ts';
export const useSignInWithGoogle = () => {
const workspaceInviteHash = useParams().workspaceInviteHash;
const { signInWithGoogle } = useAuth();
return { signInWithGoogle: () => signInWithGoogle(workspaceInviteHash) };
};

View File

@ -0,0 +1,11 @@
import { useParams } from 'react-router-dom';
import { useGetWorkspaceFromInviteHashQuery } from '~/generated/graphql.tsx';
export const useWorkspaceFromInviteHash = () => {
const workspaceInviteHash = useParams().workspaceInviteHash;
const { data: workspaceFromInviteHash } = useGetWorkspaceFromInviteHashQuery({
variables: { inviteHash: workspaceInviteHash || '' },
});
return workspaceFromInviteHash?.findWorkspaceFromInviteHash;
};

View File

@ -1,17 +1,32 @@
import { useRecoilValue } from 'recoil';
import { currentUserState } from '@/auth/states/currentUserState';
import { H2Title } from '@/ui/display/typography/components/H2Title'; import { H2Title } from '@/ui/display/typography/components/H2Title';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Button } from '@/ui/input/button/components/Button'; import { Button } from '@/ui/input/button/components/Button';
import { useEmailPasswordResetLinkMutation } from '~/generated/graphql'; import { useEmailPasswordResetLinkMutation } from '~/generated/graphql';
import { logError } from '~/utils/logError';
export const ChangePassword = () => { export const ChangePassword = () => {
const { enqueueSnackBar } = useSnackBar(); const { enqueueSnackBar } = useSnackBar();
const currentUser = useRecoilValue(currentUserState);
const [emailPasswordResetLink] = useEmailPasswordResetLinkMutation(); const [emailPasswordResetLink] = useEmailPasswordResetLinkMutation();
const handlePasswordResetClick = async () => { const handlePasswordResetClick = async () => {
if (!currentUser?.email) {
enqueueSnackBar('Invalid email', {
variant: 'error',
});
return;
}
try { try {
const { data } = await emailPasswordResetLink(); const { data } = await emailPasswordResetLink({
variables: {
email: currentUser.email,
},
});
if (data?.emailPasswordResetLink?.success) { if (data?.emailPasswordResetLink?.success) {
enqueueSnackBar('Password reset link has been sent to the email', { enqueueSnackBar('Password reset link has been sent to the email', {
variant: 'success', variant: 'success',
@ -22,7 +37,6 @@ export const ChangePassword = () => {
}); });
} }
} catch (error) { } catch (error) {
logError(error);
enqueueSnackBar((error as Error).message, { enqueueSnackBar((error as Error).message, {
variant: 'error', variant: 'error',
}); });

View File

@ -0,0 +1,31 @@
import React from 'react';
import styled from '@emotion/styled';
const StyledButtonLink = styled.a`
align-items: center;
color: ${({ theme }) => theme.font.color.light};
display: flex;
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
gap: ${({ theme }) => theme.spacing(1)};
padding: 0 ${({ theme }) => theme.spacing(1)};
text-decoration: none;
:hover {
color: ${({ theme }) => theme.font.color.tertiary};
cursor: pointer;
}
`;
export const ActionLink = (props: React.ComponentProps<'a'>) => {
return (
<StyledButtonLink
href={props.href}
onClick={props.onClick}
target={props.target}
rel={props.rel}
>
{props.children}
</StyledButtonLink>
);
};

View File

@ -1,33 +1,18 @@
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconBrandGithub } from '@/ui/display/icon'; import { IconBrandGithub } from '@/ui/display/icon';
import { ActionLink } from '@/ui/navigation/link/components/ActionLink.tsx';
import packageJson from '../../../../../../package.json'; import packageJson from '../../../../../../package.json';
import { githubLink } from '../constants'; import { githubLink } from '../constants';
const StyledVersionLink = styled.a`
align-items: center;
color: ${({ theme }) => theme.font.color.light};
display: flex;
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
gap: ${({ theme }) => theme.spacing(1)};
padding: 0 ${({ theme }) => theme.spacing(1)};
text-decoration: none;
:hover {
color: ${({ theme }) => theme.font.color.tertiary};
}
`;
export const GithubVersionLink = () => { export const GithubVersionLink = () => {
const theme = useTheme(); const theme = useTheme();
return ( return (
<StyledVersionLink href={githubLink} target="_blank" rel="noreferrer"> <ActionLink href={githubLink} target="_blank" rel="noreferrer">
<IconBrandGithub size={theme.icon.size.md} /> <IconBrandGithub size={theme.icon.size.md} />
{packageJson.version} {packageJson.version}
</StyledVersionLink> </ActionLink>
); );
}; };

View File

@ -0,0 +1,27 @@
import { Meta, StoryObj } from '@storybook/react';
import { ActionLink } from '@/ui/navigation/link/components/ActionLink.tsx';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator.tsx';
const meta: Meta<typeof ActionLink> = {
title: 'UI/navigation/link/ActionLink',
component: ActionLink,
};
export default meta;
type Story = StoryObj<typeof ActionLink>;
export const Default: Story = {
args: {
children: 'Need to reset your password?',
onClick: () => alert('Action link clicked'),
target: undefined,
rel: undefined,
},
argTypes: {
href: { control: false },
target: { type: 'string' },
rel: { type: 'string' },
},
decorators: [ComponentDecorator],
};

View File

@ -27,6 +27,7 @@ export * from './src/modules/ui/input/button/components/FloatingButtonGroup'
export * from './src/modules/ui/input/button/components/FloatingIconButton' export * from './src/modules/ui/input/button/components/FloatingIconButton'
export * from './src/modules/ui/input/button/components/FloatingIconButtonGroup' export * from './src/modules/ui/input/button/components/FloatingIconButtonGroup'
export * from './src/modules/ui/input/button/components/LightButton' export * from './src/modules/ui/input/button/components/LightButton'
export * from '@/ui/navigation/link/components/ActionLink.tsx'
export * from './src/modules/ui/input/button/components/LightIconButton' export * from './src/modules/ui/input/button/components/LightIconButton'
export * from './src/modules/ui/input/button/components/MainButton' export * from './src/modules/ui/input/button/components/MainButton'
export * from './src/modules/ui/input/button/components/RoundedIconButton' export * from './src/modules/ui/input/button/components/RoundedIconButton'

View File

@ -24,6 +24,7 @@ import { ValidatePasswordResetTokenInput } from 'src/core/auth/dto/validate-pass
import { UpdatePasswordViaResetTokenInput } from 'src/core/auth/dto/update-password-via-reset-token.input'; import { UpdatePasswordViaResetTokenInput } from 'src/core/auth/dto/update-password-via-reset-token.input';
import { EmailPasswordResetLink } from 'src/core/auth/dto/email-password-reset-link.entity'; import { EmailPasswordResetLink } from 'src/core/auth/dto/email-password-reset-link.entity';
import { InvalidatePassword } from 'src/core/auth/dto/invalidate-password.entity'; import { InvalidatePassword } from 'src/core/auth/dto/invalidate-password.entity';
import { EmailPasswordResetLinkInput } from 'src/core/auth/dto/email-password-reset-link.input';
import { ApiKeyToken, AuthTokens } from './dto/token.entity'; import { ApiKeyToken, AuthTokens } from './dto/token.entity';
import { TokenService } from './services/token.service'; import { TokenService } from './services/token.service';
@ -162,17 +163,17 @@ export class AuthResolver {
); );
} }
@UseGuards(JwtAuthGuard)
@Mutation(() => EmailPasswordResetLink) @Mutation(() => EmailPasswordResetLink)
async emailPasswordResetLink( async emailPasswordResetLink(
@AuthUser() { email }: User, @Args() emailPasswordResetInput: EmailPasswordResetLinkInput,
): Promise<EmailPasswordResetLink> { ): Promise<EmailPasswordResetLink> {
const resetToken = const resetToken = await this.tokenService.generatePasswordResetToken(
await this.tokenService.generatePasswordResetToken(email); emailPasswordResetInput.email,
);
return await this.tokenService.sendEmailPasswordResetLink( return await this.tokenService.sendEmailPasswordResetLink(
resetToken, resetToken,
email, emailPasswordResetInput.email,
); );
} }

View File

@ -0,0 +1,11 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { IsEmail, IsNotEmpty } from 'class-validator';
@ArgsType()
export class EmailPasswordResetLinkInput {
@Field(() => String)
@IsNotEmpty()
@IsEmail()
email: string;
}