-
Notifications
You must be signed in to change notification settings - Fork 11
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(auth): add login with email (#230)
- Loading branch information
Showing
7 changed files
with
325 additions
and
8 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,74 @@ | ||
import { type AuthErrors } from '@app-builder/models'; | ||
import { serverServices } from '@app-builder/services/init.server'; | ||
import { json, type LoaderArgs } from '@remix-run/node'; | ||
import { useLoaderData } from '@remix-run/react'; | ||
import { LogoStandard } from '@ui-icons'; | ||
import { type Namespace, type ParseKeys } from 'i18next'; | ||
import { useTranslation } from 'react-i18next'; | ||
|
||
import { SignInWithEmail } from './ressources/auth/login-with-email'; | ||
import { LanguagePicker } from './ressources/user/language'; | ||
|
||
export async function loader({ request }: LoaderArgs) { | ||
const { | ||
authService, | ||
sessionService: { getSession }, | ||
} = serverServices; | ||
await authService.isAuthenticated(request, { | ||
successRedirect: '/home', | ||
}); | ||
const session = await getSession(request); | ||
const error = session.get('authError'); | ||
|
||
return json({ | ||
authError: error?.message, | ||
}); | ||
} | ||
|
||
export const handle = { | ||
i18n: ['login', 'common'] satisfies Namespace, | ||
}; | ||
|
||
const errorLabels: Record<AuthErrors, ParseKeys<['login', 'common']>> = { | ||
NoAccount: 'login:errors.no_account', | ||
Unknown: 'common:errors.unknown', | ||
}; | ||
|
||
export default function LoginWithEmail() { | ||
const { t } = useTranslation(handle.i18n); | ||
const { authError } = useLoaderData<typeof loader>(); | ||
|
||
return ( | ||
<div className="from-purple-10 to-grey-02 flex h-full w-full flex-col items-center bg-gradient-to-r"> | ||
<div className="flex h-full w-full flex-col items-center bg-[url('/img/login_background.svg')] bg-no-repeat"> | ||
<div className="flex h-full max-h-80 flex-col justify-center"> | ||
<LogoStandard | ||
className="w-auto" | ||
width={undefined} | ||
height="40px" | ||
preserveAspectRatio="xMinYMid meet" | ||
aria-labelledby="marble" | ||
/> | ||
</div> | ||
<div className="bg-grey-00 mb-10 flex shrink-0 flex-col items-center rounded-2xl p-10 text-center shadow-md"> | ||
<h1 className="text-l mb-12 font-semibold">{t('login:title')}</h1> | ||
|
||
<div className="mb-1 w-full"> | ||
<SignInWithEmail /> | ||
</div> | ||
|
||
{authError && ( | ||
<p className="text-xs font-normal text-red-100"> | ||
{t(errorLabels[authError])} | ||
</p> | ||
)} | ||
|
||
<p className="text-s mt-12 font-medium"> | ||
{t('login:help.no_account')} {t('login:help.contact_us')} | ||
</p> | ||
</div> | ||
<LanguagePicker /> | ||
</div> | ||
</div> | ||
); | ||
} |
180 changes: 180 additions & 0 deletions
180
packages/app-builder/src/routes/ressources/auth/login-with-email.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,180 @@ | ||
import { | ||
FormControl, | ||
FormError, | ||
FormField, | ||
FormItem, | ||
FormLabel, | ||
} from '@app-builder/components/Form'; | ||
import { | ||
InvalidLoginCredentials, | ||
useEmailAndPasswordSignIn, | ||
UserNotFoundError, | ||
WrongPasswordError, | ||
} from '@app-builder/services/auth/auth.client'; | ||
import { clientServices } from '@app-builder/services/init.client'; | ||
import { serverServices } from '@app-builder/services/init.server'; | ||
import { zodResolver } from '@hookform/resolvers/zod'; | ||
import { type ActionArgs, redirect } from '@remix-run/node'; | ||
import { useFetcher } from '@remix-run/react'; | ||
import { Button, Input } from '@ui-design-system'; | ||
import { FormProvider, useForm, useFormContext } from 'react-hook-form'; | ||
import toast from 'react-hot-toast'; | ||
import { useTranslation } from 'react-i18next'; | ||
import { ClientOnly } from 'remix-utils'; | ||
import * as z from 'zod'; | ||
|
||
export function loader() { | ||
return redirect('/login-with-email'); | ||
} | ||
|
||
export async function action({ request }: ActionArgs) { | ||
const { authService } = serverServices; | ||
return await authService.authenticate(request, { | ||
successRedirect: '/home', | ||
failureRedirect: '/login-with-email', | ||
}); | ||
} | ||
|
||
const emailAndPasswordFormSchema = z.object({ | ||
credentials: z.object({ | ||
email: z.string().email(), | ||
password: z.string().min(1, 'Required'), | ||
}), | ||
}); | ||
type EmailAndPasswordFormValues = z.infer<typeof emailAndPasswordFormSchema>; | ||
|
||
export function SignInWithEmail() { | ||
const { t } = useTranslation(); | ||
|
||
const formMethods = useForm<z.infer<typeof emailAndPasswordFormSchema>>({ | ||
resolver: zodResolver(emailAndPasswordFormSchema), | ||
defaultValues: { | ||
credentials: { email: '', password: '' }, | ||
}, | ||
}); | ||
const { control } = formMethods; | ||
|
||
const children = ( | ||
<> | ||
<FormField | ||
control={control} | ||
name="credentials.email" | ||
render={({ field }) => ( | ||
<FormItem className="flex flex-col items-start gap-2"> | ||
<FormLabel>{t('login:sign_in_with_email.email')}</FormLabel> | ||
<FormControl> | ||
<Input className="w-full" type="email" {...field} /> | ||
</FormControl> | ||
<FormError /> | ||
</FormItem> | ||
)} | ||
/> | ||
<FormField | ||
control={control} | ||
name="credentials.password" | ||
render={({ field }) => ( | ||
<FormItem className="flex flex-col items-start gap-2"> | ||
<FormLabel>{t('login:sign_in_with_email.password')}</FormLabel> | ||
<FormControl> | ||
<Input className="w-full" type="password" {...field} /> | ||
</FormControl> | ||
<FormError /> | ||
</FormItem> | ||
)} | ||
/> | ||
<FormField | ||
control={control} | ||
name="credentials" | ||
render={() => <FormError />} | ||
/> | ||
<Button type="submit">{t('login:sign_in_with_email.sign_in')}</Button> | ||
</> | ||
); | ||
|
||
return ( | ||
<FormProvider {...formMethods}> | ||
<ClientOnly | ||
fallback={<SignInWithEmailForm>{children}</SignInWithEmailForm>} | ||
> | ||
{() => ( | ||
<ClientSignInWithEmailForm>{children}</ClientSignInWithEmailForm> | ||
)} | ||
</ClientOnly> | ||
</FormProvider> | ||
); | ||
} | ||
|
||
function SignInWithEmailForm(props: React.ComponentPropsWithoutRef<'form'>) { | ||
return <form noValidate className="flex flex-col gap-4" {...props} />; | ||
} | ||
|
||
function ClientSignInWithEmailForm({ | ||
children, | ||
}: { | ||
children: React.ReactNode; | ||
}) { | ||
const { t } = useTranslation(); | ||
const fetcher = useFetcher(); | ||
|
||
const emailAndPasswordSignIn = useEmailAndPasswordSignIn( | ||
clientServices.authenticationClientService | ||
); | ||
|
||
const { handleSubmit, setError } = | ||
useFormContext<EmailAndPasswordFormValues>(); | ||
|
||
const handleEmailSignIn = handleSubmit( | ||
async ({ credentials: { email, password } }) => { | ||
try { | ||
const result = await emailAndPasswordSignIn(email, password); | ||
|
||
if (!result) return; | ||
const { idToken, csrf } = result; | ||
if (!idToken) return; | ||
fetcher.submit( | ||
{ idToken, csrf }, | ||
{ method: 'POST', action: '/ressources/auth/login-with-email' } | ||
); | ||
} catch (error) { | ||
if (error instanceof UserNotFoundError) { | ||
setError( | ||
'credentials.email', | ||
{ | ||
message: t('login:sign_in_with_email.errors.user_not_found'), | ||
}, | ||
{ shouldFocus: true } | ||
); | ||
} else if (error instanceof WrongPasswordError) { | ||
setError( | ||
'credentials.password', | ||
{ | ||
message: t( | ||
'login:sign_in_with_email.errors.wrong_password_error' | ||
), | ||
}, | ||
{ shouldFocus: true } | ||
); | ||
} else if (error instanceof InvalidLoginCredentials) { | ||
setError('credentials', { | ||
message: t( | ||
'login:sign_in_with_email.errors.invalid_login_credentials' | ||
), | ||
}); | ||
} else { | ||
//TODO(sentry): colect unexpected errors | ||
toast.error(t('common:errors.unknown')); | ||
} | ||
} | ||
} | ||
); | ||
|
||
return ( | ||
<SignInWithEmailForm | ||
onSubmit={(e) => { | ||
void handleEmailSignIn(e); | ||
}} | ||
> | ||
{children} | ||
</SignInWithEmailForm> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.