Skip to content

Commit

Permalink
feat(auth): add login with email (#230)
Browse files Browse the repository at this point in the history
  • Loading branch information
balzdur authored Oct 25, 2023
1 parent 3567bf4 commit 4a4375a
Show file tree
Hide file tree
Showing 7 changed files with 325 additions and 8 deletions.
8 changes: 7 additions & 1 deletion packages/app-builder/public/locales/en/login.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,11 @@
"help.no_account": "Don’t have an account?",
"help.contact_us": "Contact us",
"sign_in.google": "Sign in with Google",
"errors.no_account": "No Marble account found for this address."
"errors.no_account": "No Marble account found for this address.",
"sign_in_with_email.email": "Email",
"sign_in_with_email.password": "Password",
"sign_in_with_email.sign_in": "Sign in",
"sign_in_with_email.errors.user_not_found": "No user account found for this address.",
"sign_in_with_email.errors.wrong_password_error": "Wrong password.",
"sign_in_with_email.errors.invalid_login_credentials": "Invalid login credentials."
}
7 changes: 5 additions & 2 deletions packages/app-builder/src/infra/firebase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,16 @@ import {
connectAuthEmulator,
getAuth,
GoogleAuthProvider,
signInWithEmailAndPassword,
signInWithPopup,
} from 'firebase/auth';

export type FirebaseClientWrapper = {
app: FirebaseApp;
clientAuth: Auth;
googleAuthProvider: GoogleAuthProvider;
signIn: typeof signInWithPopup;
signInWithOAuth: typeof signInWithPopup;
signInWithEmailAndPassword: typeof signInWithEmailAndPassword;
};

export function initializeFirebaseClient({
Expand All @@ -40,6 +42,7 @@ export function initializeFirebaseClient({
app,
clientAuth,
googleAuthProvider,
signIn: signInWithPopup,
signInWithOAuth: signInWithPopup,
signInWithEmailAndPassword: signInWithEmailAndPassword,
};
}
22 changes: 19 additions & 3 deletions packages/app-builder/src/repositories/AuthenticationRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,19 @@ import { type FirebaseClientWrapper } from '@app-builder/infra/firebase';

export interface AuthenticationClientRepository {
googleSignIn: (locale: string) => Promise<string>;
emailAndPasswordSignIn: (
locale: string,
email: string,
password: string
) => Promise<string>;
firebaseIdToken: () => Promise<string>;
}

export function getAuthenticationClientRepository({
clientAuth,
googleAuthProvider,
signIn,
signInWithOAuth,
signInWithEmailAndPassword,
}: FirebaseClientWrapper): AuthenticationClientRepository {
function getClientAuth(locale: string) {
if (locale) {
Expand All @@ -21,7 +27,17 @@ export function getAuthenticationClientRepository({

async function googleSignIn(locale: string) {
const auth = getClientAuth(locale);
const credential = await signIn(auth, googleAuthProvider);
const credential = await signInWithOAuth(auth, googleAuthProvider);
return credential.user.getIdToken();
}

async function emailAndPasswordSignIn(
locale: string,
email: string,
password: string
) {
const auth = getClientAuth(locale);
const credential = await signInWithEmailAndPassword(auth, email, password);
return credential.user.getIdToken();
}

Expand All @@ -33,5 +49,5 @@ export function getAuthenticationClientRepository({
return currentUser.getIdToken();
};

return { googleSignIn, firebaseIdToken };
return { googleSignIn, emailAndPasswordSignIn, firebaseIdToken };
}
74 changes: 74 additions & 0 deletions packages/app-builder/src/routes/login-with-email.tsx
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 packages/app-builder/src/routes/ressources/auth/login-with-email.tsx
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>
);
}
4 changes: 2 additions & 2 deletions packages/app-builder/src/routes/ressources/auth/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ function ClientSignInWithGoogle() {
clientServices.authenticationClientService
);

const handleGoogleSingIn = async () => {
const handleGoogleSignIn = async () => {
const result = await googleSignIn();
if (!result) return;
const { idToken, csrf } = result;
Expand All @@ -60,7 +60,7 @@ function ClientSignInWithGoogle() {
return (
<SignInWithGoogleButton
onClick={() => {
void handleGoogleSingIn();
void handleGoogleSignIn();
}}
/>
);
Expand Down
Loading

0 comments on commit 4a4375a

Please sign in to comment.