diff --git a/packages/app-builder/public/locales/en/login.json b/packages/app-builder/public/locales/en/login.json index c6da75780..71443a423 100644 --- a/packages/app-builder/public/locales/en/login.json +++ b/packages/app-builder/public/locales/en/login.json @@ -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." } diff --git a/packages/app-builder/src/infra/firebase.ts b/packages/app-builder/src/infra/firebase.ts index 82af1618f..a36780254 100644 --- a/packages/app-builder/src/infra/firebase.ts +++ b/packages/app-builder/src/infra/firebase.ts @@ -8,6 +8,7 @@ import { connectAuthEmulator, getAuth, GoogleAuthProvider, + signInWithEmailAndPassword, signInWithPopup, } from 'firebase/auth'; @@ -15,7 +16,8 @@ export type FirebaseClientWrapper = { app: FirebaseApp; clientAuth: Auth; googleAuthProvider: GoogleAuthProvider; - signIn: typeof signInWithPopup; + signInWithOAuth: typeof signInWithPopup; + signInWithEmailAndPassword: typeof signInWithEmailAndPassword; }; export function initializeFirebaseClient({ @@ -40,6 +42,7 @@ export function initializeFirebaseClient({ app, clientAuth, googleAuthProvider, - signIn: signInWithPopup, + signInWithOAuth: signInWithPopup, + signInWithEmailAndPassword: signInWithEmailAndPassword, }; } diff --git a/packages/app-builder/src/repositories/AuthenticationRepository.ts b/packages/app-builder/src/repositories/AuthenticationRepository.ts index bd1cf6e21..3d00fc81c 100644 --- a/packages/app-builder/src/repositories/AuthenticationRepository.ts +++ b/packages/app-builder/src/repositories/AuthenticationRepository.ts @@ -2,13 +2,19 @@ import { type FirebaseClientWrapper } from '@app-builder/infra/firebase'; export interface AuthenticationClientRepository { googleSignIn: (locale: string) => Promise; + emailAndPasswordSignIn: ( + locale: string, + email: string, + password: string + ) => Promise; firebaseIdToken: () => Promise; } export function getAuthenticationClientRepository({ clientAuth, googleAuthProvider, - signIn, + signInWithOAuth, + signInWithEmailAndPassword, }: FirebaseClientWrapper): AuthenticationClientRepository { function getClientAuth(locale: string) { if (locale) { @@ -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(); } @@ -33,5 +49,5 @@ export function getAuthenticationClientRepository({ return currentUser.getIdToken(); }; - return { googleSignIn, firebaseIdToken }; + return { googleSignIn, emailAndPasswordSignIn, firebaseIdToken }; } diff --git a/packages/app-builder/src/routes/login-with-email.tsx b/packages/app-builder/src/routes/login-with-email.tsx new file mode 100644 index 000000000..ebf58dd0e --- /dev/null +++ b/packages/app-builder/src/routes/login-with-email.tsx @@ -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> = { + NoAccount: 'login:errors.no_account', + Unknown: 'common:errors.unknown', +}; + +export default function LoginWithEmail() { + const { t } = useTranslation(handle.i18n); + const { authError } = useLoaderData(); + + return ( +
+
+
+ +
+
+

{t('login:title')}

+ +
+ +
+ + {authError && ( +

+ {t(errorLabels[authError])} +

+ )} + +

+ {t('login:help.no_account')} {t('login:help.contact_us')} +

+
+ +
+
+ ); +} diff --git a/packages/app-builder/src/routes/ressources/auth/login-with-email.tsx b/packages/app-builder/src/routes/ressources/auth/login-with-email.tsx new file mode 100644 index 000000000..6bfc0ec19 --- /dev/null +++ b/packages/app-builder/src/routes/ressources/auth/login-with-email.tsx @@ -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; + +export function SignInWithEmail() { + const { t } = useTranslation(); + + const formMethods = useForm>({ + resolver: zodResolver(emailAndPasswordFormSchema), + defaultValues: { + credentials: { email: '', password: '' }, + }, + }); + const { control } = formMethods; + + const children = ( + <> + ( + + {t('login:sign_in_with_email.email')} + + + + + + )} + /> + ( + + {t('login:sign_in_with_email.password')} + + + + + + )} + /> + } + /> + + + ); + + return ( + + {children}} + > + {() => ( + {children} + )} + + + ); +} + +function SignInWithEmailForm(props: React.ComponentPropsWithoutRef<'form'>) { + return
; +} + +function ClientSignInWithEmailForm({ + children, +}: { + children: React.ReactNode; +}) { + const { t } = useTranslation(); + const fetcher = useFetcher(); + + const emailAndPasswordSignIn = useEmailAndPasswordSignIn( + clientServices.authenticationClientService + ); + + const { handleSubmit, setError } = + useFormContext(); + + 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 ( + { + void handleEmailSignIn(e); + }} + > + {children} + + ); +} diff --git a/packages/app-builder/src/routes/ressources/auth/login.tsx b/packages/app-builder/src/routes/ressources/auth/login.tsx index c7ba254ae..d186f2752 100644 --- a/packages/app-builder/src/routes/ressources/auth/login.tsx +++ b/packages/app-builder/src/routes/ressources/auth/login.tsx @@ -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; @@ -60,7 +60,7 @@ function ClientSignInWithGoogle() { return ( { - void handleGoogleSingIn(); + void handleGoogleSignIn(); }} /> ); diff --git a/packages/app-builder/src/services/auth/auth.client.ts b/packages/app-builder/src/services/auth/auth.client.ts index 5295ed6ce..db6f74876 100644 --- a/packages/app-builder/src/services/auth/auth.client.ts +++ b/packages/app-builder/src/services/auth/auth.client.ts @@ -1,6 +1,7 @@ import { type AuthenticationClientRepository } from '@app-builder/repositories/AuthenticationRepository'; import { getClientEnv } from '@app-builder/utils/environment.client'; import { marbleApi } from '@marble-api'; +import { FirebaseError } from 'firebase/app'; import { useTranslation } from 'react-i18next'; import { useAuthenticityToken } from 'remix-utils'; @@ -37,6 +38,43 @@ export function useGoogleSignIn({ }; } +export function useEmailAndPasswordSignIn({ + authenticationClientRepository, +}: AuthenticationClientService) { + const { + i18n: { language }, + } = useTranslation(); + const csrf = useAuthenticityToken(); + + return async (email: string, password: string) => { + try { + const idToken = + await authenticationClientRepository.emailAndPasswordSignIn( + language, + email, + password + ); + return { idToken, csrf }; + } catch (error) { + if (error instanceof FirebaseError) { + switch (error.code) { + case 'auth/user-not-found': + throw new UserNotFoundError(); + case 'auth/wrong-password': + throw new WrongPasswordError(); + case 'auth/invalid-login-credentials': + throw new InvalidLoginCredentials(); + } + } + throw error; + } + }; +} + +export class UserNotFoundError extends Error {} +export class WrongPasswordError extends Error {} +export class InvalidLoginCredentials extends Error {} + export function useBackendInfo({ authenticationClientRepository, }: AuthenticationClientService) {