mirror of
https://github.com/twentyhq/twenty.git
synced 2024-11-27 11:03:40 +03:00
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:
parent
917fc5bd4d
commit
3cbf958a1c
@ -24,6 +24,11 @@ export type Scalars = {
|
||||
Upload: { input: any; output: any; }
|
||||
};
|
||||
|
||||
export type ApiKeyToken = {
|
||||
__typename?: 'ApiKeyToken';
|
||||
token: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type AuthProviders = {
|
||||
__typename?: 'AuthProviders';
|
||||
google: Scalars['Boolean']['output'];
|
||||
@ -43,6 +48,11 @@ export type AuthTokenPair = {
|
||||
refreshToken: AuthToken;
|
||||
};
|
||||
|
||||
export type AuthTokens = {
|
||||
__typename?: 'AuthTokens';
|
||||
tokens: AuthTokenPair;
|
||||
};
|
||||
|
||||
export type Billing = {
|
||||
__typename?: 'Billing';
|
||||
billingUrl: Scalars['String']['output'];
|
||||
@ -136,6 +146,12 @@ export type DeleteOneRelationInput = {
|
||||
id: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type EmailPasswordResetLink = {
|
||||
__typename?: 'EmailPasswordResetLink';
|
||||
/** Boolean that confirms query was dispatched */
|
||||
success: Scalars['Boolean']['output'];
|
||||
};
|
||||
|
||||
export type FieldConnection = {
|
||||
__typename?: 'FieldConnection';
|
||||
/** Array of edges. */
|
||||
@ -214,8 +230,20 @@ export type IdFilterComparison = {
|
||||
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 = {
|
||||
__typename?: 'Mutation';
|
||||
challenge: LoginToken;
|
||||
createOneField: Field;
|
||||
createOneObject: Object;
|
||||
createOneRelation: Relation;
|
||||
@ -223,11 +251,25 @@ export type Mutation = {
|
||||
deleteOneObject: Object;
|
||||
deleteOneRelation: RelationDeleteResponse;
|
||||
deleteUser: User;
|
||||
emailPasswordResetLink: EmailPasswordResetLink;
|
||||
generateApiKeyToken: ApiKeyToken;
|
||||
generateTransientToken: TransientToken;
|
||||
impersonate: Verify;
|
||||
renewToken: AuthTokens;
|
||||
signUp: LoginToken;
|
||||
updateOneField: Field;
|
||||
updateOneObject: Object;
|
||||
updatePasswordViaResetToken: InvalidatePassword;
|
||||
uploadFile: Scalars['String']['output'];
|
||||
uploadImage: 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 = {
|
||||
input: UpdateOneFieldMetadataInput;
|
||||
};
|
||||
@ -271,6 +341,12 @@ export type MutationUpdateOneObjectArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationUpdatePasswordViaResetTokenArgs = {
|
||||
newPassword: Scalars['String']['input'];
|
||||
passwordResetToken: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationUploadFileArgs = {
|
||||
file: Scalars['Upload']['input'];
|
||||
fileFolder?: InputMaybe<FileFolder>;
|
||||
@ -287,6 +363,11 @@ export type MutationUploadProfilePictureArgs = {
|
||||
file: Scalars['Upload']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationVerifyArgs = {
|
||||
loginToken: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type ObjectConnection = {
|
||||
__typename?: 'ObjectConnection';
|
||||
/** Array of edges. */
|
||||
@ -321,13 +402,27 @@ export type PageInfo = {
|
||||
|
||||
export type Query = {
|
||||
__typename?: 'Query';
|
||||
checkUserExists: UserExists;
|
||||
checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid;
|
||||
currentUser: User;
|
||||
field: Field;
|
||||
fields: FieldConnection;
|
||||
findWorkspaceFromInviteHash: Workspace;
|
||||
object: Object;
|
||||
objects: ObjectConnection;
|
||||
relation: Relation;
|
||||
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 = {
|
||||
id: Scalars['ID']['input'];
|
||||
};
|
||||
@ -362,6 +462,11 @@ export type QueryRelationsArgs = {
|
||||
paging?: CursorPaging;
|
||||
};
|
||||
|
||||
|
||||
export type QueryValidatePasswordResetTokenArgs = {
|
||||
passwordResetToken: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type RefreshToken = {
|
||||
__typename?: 'RefreshToken';
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
@ -424,6 +529,19 @@ export type Telemetry = {
|
||||
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 = {
|
||||
__typename?: 'TimelineThreadParticipant';
|
||||
avatarUrl: Scalars['String']['output'];
|
||||
@ -435,6 +553,11 @@ export type TimelineThreadParticipant = {
|
||||
workspaceMemberId?: Maybe<Scalars['ID']['output']>;
|
||||
};
|
||||
|
||||
export type TransientToken = {
|
||||
__typename?: 'TransientToken';
|
||||
transientToken: AuthToken;
|
||||
};
|
||||
|
||||
export type UpdateFieldInput = {
|
||||
defaultValue?: InputMaybe<Scalars['JSON']['input']>;
|
||||
description?: InputMaybe<Scalars['String']['input']>;
|
||||
@ -502,6 +625,23 @@ export type UserEdge = {
|
||||
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 = {
|
||||
__typename?: 'Workspace';
|
||||
allowImpersonation: Scalars['Boolean']['output'];
|
||||
@ -524,6 +664,11 @@ export type WorkspaceEdge = {
|
||||
node: Workspace;
|
||||
};
|
||||
|
||||
export type WorkspaceInviteHashValid = {
|
||||
__typename?: 'WorkspaceInviteHashValid';
|
||||
isValid: Scalars['Boolean']['output'];
|
||||
};
|
||||
|
||||
export type WorkspaceMember = {
|
||||
__typename?: 'WorkspaceMember';
|
||||
avatarUrl?: Maybe<Scalars['String']['output']>;
|
||||
|
@ -265,6 +265,11 @@ export type MutationDeleteOneObjectArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationEmailPasswordResetLinkArgs = {
|
||||
email: Scalars['String'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationGenerateApiKeyTokenArgs = {
|
||||
apiKeyId: 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 EmailPasswordResetLinkMutationVariables = Exact<{ [key: string]: never; }>;
|
||||
export type EmailPasswordResetLinkMutationVariables = Exact<{
|
||||
email: Scalars['String'];
|
||||
}>;
|
||||
|
||||
|
||||
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 ChallengeMutationOptions = Apollo.BaseMutationOptions<ChallengeMutation, ChallengeMutationVariables>;
|
||||
export const EmailPasswordResetLinkDocument = gql`
|
||||
mutation EmailPasswordResetLink {
|
||||
emailPasswordResetLink {
|
||||
mutation EmailPasswordResetLink($email: String!) {
|
||||
emailPasswordResetLink(email: $email) {
|
||||
success
|
||||
}
|
||||
}
|
||||
@ -1164,6 +1171,7 @@ export type EmailPasswordResetLinkMutationFn = Apollo.MutationFunction<EmailPass
|
||||
* @example
|
||||
* const [emailPasswordResetLinkMutation, { data, loading, error }] = useEmailPasswordResetLinkMutation({
|
||||
* variables: {
|
||||
* email: // value for 'email'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const EMAIL_PASSWORD_RESET_Link = gql`
|
||||
mutation EmailPasswordResetLink {
|
||||
emailPasswordResetLink {
|
||||
mutation EmailPasswordResetLink($email: String!) {
|
||||
emailPasswordResetLink(email: $email) {
|
||||
success
|
||||
}
|
||||
}
|
||||
|
@ -1,13 +1,20 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
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 { Loader } from '@/ui/feedback/loader/components/Loader';
|
||||
import { MainButton } from '@/ui/input/button/components/MainButton';
|
||||
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 { Logo } from '../../components/Logo';
|
||||
@ -43,24 +50,20 @@ const StyledInputContainer = styled.div`
|
||||
`;
|
||||
|
||||
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 {
|
||||
authProviders,
|
||||
signInWithGoogle,
|
||||
signInUpStep,
|
||||
signInUpMode,
|
||||
showErrors,
|
||||
setShowErrors,
|
||||
continueWithCredentials,
|
||||
continueWithEmail,
|
||||
submitCredentials,
|
||||
form: {
|
||||
control,
|
||||
watch,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
},
|
||||
workspace,
|
||||
} = useSignInUp();
|
||||
} = useSignInUp(form);
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
@ -72,7 +75,7 @@ export const SignInUpForm = () => {
|
||||
continueWithCredentials();
|
||||
} else if (signInUpStep === SignInUpStep.Password) {
|
||||
setShowErrors(true);
|
||||
handleSubmit(submitCredentials)();
|
||||
form.handleSubmit(submitCredentials)();
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -88,10 +91,10 @@ export const SignInUpForm = () => {
|
||||
|
||||
return signInUpMode === SignInUpMode.SignIn
|
||||
? 'Sign in'
|
||||
: isSubmitting
|
||||
: form.formState.isSubmitting
|
||||
? 'Creating workspace'
|
||||
: 'Sign up';
|
||||
}, [signInUpMode, signInUpStep, isSubmitting]);
|
||||
}, [signInUpMode, signInUpStep, form.formState.isSubmitting]);
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (signInUpMode === SignInUpMode.Invite) {
|
||||
@ -141,7 +144,7 @@ export const SignInUpForm = () => {
|
||||
>
|
||||
<Controller
|
||||
name="email"
|
||||
control={control}
|
||||
control={form.control}
|
||||
render={({
|
||||
field: { onChange, onBlur, value },
|
||||
fieldState: { error },
|
||||
@ -180,7 +183,7 @@ export const SignInUpForm = () => {
|
||||
>
|
||||
<Controller
|
||||
name="password"
|
||||
control={control}
|
||||
control={form.control}
|
||||
render={({
|
||||
field: { onChange, onBlur, value },
|
||||
fieldState: { error },
|
||||
@ -218,24 +221,32 @@ export const SignInUpForm = () => {
|
||||
return;
|
||||
}
|
||||
setShowErrors(true);
|
||||
handleSubmit(submitCredentials)();
|
||||
form.handleSubmit(submitCredentials)();
|
||||
}}
|
||||
Icon={() => isSubmitting && <Loader />}
|
||||
Icon={() => form.formState.isSubmitting && <Loader />}
|
||||
disabled={
|
||||
SignInUpStep.Init
|
||||
? false
|
||||
: signInUpStep === SignInUpStep.Email
|
||||
? !watch('email')
|
||||
: !watch('email') || !watch('password') || isSubmitting
|
||||
? !form.watch('email')
|
||||
: !form.watch('email') ||
|
||||
!form.watch('password') ||
|
||||
form.formState.isSubmitting
|
||||
}
|
||||
fullWidth
|
||||
/>
|
||||
</StyledForm>
|
||||
</StyledContentContainer>
|
||||
{signInUpStep === SignInUpStep.Password ? (
|
||||
<ActionLink onClick={handleResetPassword(form.getValues('email'))}>
|
||||
Forgot your password?
|
||||
</ActionLink>
|
||||
) : (
|
||||
<StyledFooterNote>
|
||||
By using Twenty, you agree to the Terms of Service and Data Processing
|
||||
Agreement.
|
||||
</StyledFooterNote>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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 };
|
||||
};
|
@ -1,22 +1,17 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { SubmitHandler, useForm } from 'react-hook-form';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { SubmitHandler, UseFormReturn } from 'react-hook-form';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { z } from 'zod';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
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 { isSignInPrefilledState } from '@/client-config/states/isSignInPrefilledState';
|
||||
import { AppPath } from '@/types/AppPath';
|
||||
import { PageHotkeyScope } from '@/types/PageHotkeyScope';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
|
||||
import { useGetWorkspaceFromInviteHashQuery } from '~/generated/graphql';
|
||||
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation';
|
||||
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
import { PASSWORD_REGEX } from '../../utils/passwordRegex';
|
||||
|
||||
export enum SignInUpMode {
|
||||
SignIn = 'sign-in',
|
||||
@ -30,27 +25,11 @@ export enum SignInUpStep {
|
||||
Password = 'password',
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
type Form = z.infer<typeof validationSchema>;
|
||||
|
||||
export const useSignInUp = () => {
|
||||
export const useSignInUp = (form: UseFormReturn<Form>) => {
|
||||
const navigate = useNavigate();
|
||||
const { enqueueSnackBar } = useSnackBar();
|
||||
const isMatchingLocation = useIsMatchingLocation();
|
||||
|
||||
const [authProviders] = useRecoilState(authProvidersState);
|
||||
const isSignInPrefilled = useRecoilValue(isSignInPrefilledState);
|
||||
const billing = useRecoilValue(billingState);
|
||||
|
||||
const workspaceInviteHash = useParams().workspaceInviteHash;
|
||||
const [signInUpStep, setSignInUpStep] = useState<SignInUpStep>(
|
||||
SignInUpStep.Init,
|
||||
@ -64,31 +43,9 @@ export const useSignInUp = () => {
|
||||
? SignInUpMode.SignIn
|
||||
: 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 {
|
||||
signInWithCredentials,
|
||||
signUpWithCredentials,
|
||||
signInWithGoogle,
|
||||
checkUserExists: { checkUserExistsQuery },
|
||||
} = useAuth();
|
||||
|
||||
@ -169,10 +126,6 @@ export const useSignInUp = () => {
|
||||
],
|
||||
);
|
||||
|
||||
const goBackToEmailStep = useCallback(() => {
|
||||
setSignInUpStep(SignInUpStep.Email);
|
||||
}, [setSignInUpStep]);
|
||||
|
||||
useScopedHotkeys(
|
||||
'enter',
|
||||
() => {
|
||||
@ -193,17 +146,10 @@ export const useSignInUp = () => {
|
||||
);
|
||||
|
||||
return {
|
||||
authProviders,
|
||||
signInWithGoogle: () => signInWithGoogle(workspaceInviteHash),
|
||||
signInUpStep,
|
||||
signInUpMode,
|
||||
showErrors,
|
||||
setShowErrors,
|
||||
continueWithCredentials,
|
||||
continueWithEmail,
|
||||
goBackToEmailStep,
|
||||
submitCredentials,
|
||||
form,
|
||||
workspace: workspaceFromInviteHash?.findWorkspaceFromInviteHash,
|
||||
};
|
||||
};
|
||||
|
@ -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 };
|
||||
};
|
@ -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) };
|
||||
};
|
@ -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;
|
||||
};
|
@ -1,17 +1,32 @@
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { currentUserState } from '@/auth/states/currentUserState';
|
||||
import { H2Title } from '@/ui/display/typography/components/H2Title';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { Button } from '@/ui/input/button/components/Button';
|
||||
import { useEmailPasswordResetLinkMutation } from '~/generated/graphql';
|
||||
import { logError } from '~/utils/logError';
|
||||
|
||||
export const ChangePassword = () => {
|
||||
const { enqueueSnackBar } = useSnackBar();
|
||||
|
||||
const currentUser = useRecoilValue(currentUserState);
|
||||
|
||||
const [emailPasswordResetLink] = useEmailPasswordResetLinkMutation();
|
||||
|
||||
const handlePasswordResetClick = async () => {
|
||||
if (!currentUser?.email) {
|
||||
enqueueSnackBar('Invalid email', {
|
||||
variant: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await emailPasswordResetLink();
|
||||
const { data } = await emailPasswordResetLink({
|
||||
variables: {
|
||||
email: currentUser.email,
|
||||
},
|
||||
});
|
||||
if (data?.emailPasswordResetLink?.success) {
|
||||
enqueueSnackBar('Password reset link has been sent to the email', {
|
||||
variant: 'success',
|
||||
@ -22,7 +37,6 @@ export const ChangePassword = () => {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error);
|
||||
enqueueSnackBar((error as Error).message, {
|
||||
variant: 'error',
|
||||
});
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -1,33 +1,18 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { IconBrandGithub } from '@/ui/display/icon';
|
||||
import { ActionLink } from '@/ui/navigation/link/components/ActionLink.tsx';
|
||||
|
||||
import packageJson from '../../../../../../package.json';
|
||||
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 = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<StyledVersionLink href={githubLink} target="_blank" rel="noreferrer">
|
||||
<ActionLink href={githubLink} target="_blank" rel="noreferrer">
|
||||
<IconBrandGithub size={theme.icon.size.md} />
|
||||
{packageJson.version}
|
||||
</StyledVersionLink>
|
||||
</ActionLink>
|
||||
);
|
||||
};
|
||||
|
@ -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],
|
||||
};
|
@ -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/FloatingIconButtonGroup'
|
||||
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/MainButton'
|
||||
export * from './src/modules/ui/input/button/components/RoundedIconButton'
|
||||
|
@ -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 { EmailPasswordResetLink } from 'src/core/auth/dto/email-password-reset-link.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 { TokenService } from './services/token.service';
|
||||
@ -162,17 +163,17 @@ export class AuthResolver {
|
||||
);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Mutation(() => EmailPasswordResetLink)
|
||||
async emailPasswordResetLink(
|
||||
@AuthUser() { email }: User,
|
||||
@Args() emailPasswordResetInput: EmailPasswordResetLinkInput,
|
||||
): Promise<EmailPasswordResetLink> {
|
||||
const resetToken =
|
||||
await this.tokenService.generatePasswordResetToken(email);
|
||||
const resetToken = await this.tokenService.generatePasswordResetToken(
|
||||
emailPasswordResetInput.email,
|
||||
);
|
||||
|
||||
return await this.tokenService.sendEmailPasswordResetLink(
|
||||
resetToken,
|
||||
email,
|
||||
emailPasswordResetInput.email,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
Loading…
Reference in New Issue
Block a user