From 051a20617a7f8ba46ebde849395bcbc7ce06b225 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Jul 2025 13:03:13 +0000 Subject: [PATCH] Create LoginForm component with email, OAuth, and sign-up functionality Co-authored-by: natemkelley --- .../components/features/auth/LoginForm.tsx | 455 ++++++++++++++++++ 1 file changed, 455 insertions(+) create mode 100644 web/src/components/features/auth/LoginForm.tsx diff --git a/web/src/components/features/auth/LoginForm.tsx b/web/src/components/features/auth/LoginForm.tsx new file mode 100644 index 000000000..2d4ea216a --- /dev/null +++ b/web/src/components/features/auth/LoginForm.tsx @@ -0,0 +1,455 @@ +'use client'; + +import { rustErrorHandler } from '@/api/buster_rest/errors'; +import { Button } from '@/components/ui/buttons'; +import { SuccessCard } from '@/components/ui/card/SuccessCard'; +import Github from '@/components/ui/icons/customIcons/Github'; +import Google from '@/components/ui/icons/customIcons/Google'; +import Microsoft from '@/components/ui/icons/customIcons/Microsoft'; +import { Input } from '@/components/ui/inputs'; +import { Text, Title } from '@/components/ui/typography'; +import { useMemoizedFn } from '@/hooks'; +import { cn } from '@/lib/classMerge'; +import { isValidEmail } from '@/lib/email'; +import { + signInWithAzure, + signInWithEmailAndPassword, + signInWithGithub, + signInWithGoogle, + signUp, +} from '@/lib/supabase/signIn'; +import { inputHasText } from '@/lib/text'; +import { BusterRoutes, createBusterRoute } from '@/routes/busterRoutes'; +import Cookies from 'js-cookie'; +import Link from 'next/link'; +import React, { useMemo, useState } from 'react'; +import { useHotkeys } from 'react-hotkeys-hook'; +import { PolicyCheck } from './PolicyCheck'; + +const DEFAULT_CREDENTIALS = { + email: process.env.NEXT_PUBLIC_USER || '', + password: process.env.NEXT_PUBLIC_USER_PASSWORD || '', +}; + +export const LoginForm: React.FC = () => { + const [loading, setLoading] = useState<'google' | 'github' | 'azure' | 'email' | null>(null); + const [errorMessages, setErrorMessages] = useState([]); + const [signUpFlow, setSignUpFlow] = useState(true); + const [signUpSuccess, setSignUpSuccess] = useState(false); + + const errorFallback = useMemoizedFn((error: unknown) => { + const errorMessage = rustErrorHandler(error); + if (errorMessage?.message) { + setErrorMessages([errorMessage.message]); + } else { + setErrorMessages(['An error occurred']); + } + }); + + const onSignInWithUsernameAndPassword = useMemoizedFn( + async ({ email, password }: { email: string; password: string }) => { + setLoading('email'); + try { + await signInWithEmailAndPassword({ email, password }); + } catch (error: unknown) { + console.error(error); + errorFallback(error); + setLoading(null); + } + }, + ); + + const onSignInWithGoogle = useMemoizedFn(async () => { + setLoading('google'); + try { + await signInWithGoogle(); + } catch (error: unknown) { + console.error(error); + errorFallback(error); + setLoading(null); + } + }); + + const onSignInWithGithub = useMemoizedFn(async () => { + setLoading('github'); + try { + const res = await signInWithGithub(); + } catch (error: unknown) { + console.error(error); + errorFallback(error); + setLoading(null); + } + }); + + const onSignInWithAzure = useMemoizedFn(async () => { + setLoading('azure'); + try { + await signInWithAzure(); + } catch (error: unknown) { + errorFallback(error); + setLoading(null); + } + }); + + const onSignUp = useMemoizedFn(async (d: { email: string; password: string }) => { + setLoading('email'); + try { + await signUp(d); + setSignUpSuccess(true); + } catch (error: unknown) { + console.error(error); + errorFallback(error); + setLoading(null); + } + }); + + const onSubmitClick = useMemoizedFn((d: { email: string; password: string }) => { + try { + setErrorMessages([]); + setLoading('email'); + + if (signUpFlow) onSignUp(d); + else onSignInWithUsernameAndPassword(d); + } catch (error: unknown) { + console.error(error); + const errorMessage = rustErrorHandler(error); + if (errorMessage?.message === 'User already registered') { + onSignInWithUsernameAndPassword(d); + return; + } + if (errorMessage?.message) { + setErrorMessages([errorMessage.message]); + } else { + setErrorMessages(['An error occurred']); + } + setLoading(null); + } + }); + + return ( +
+
+ {signUpSuccess ? ( + + ) : ( + + )} +
+
+ ); +}; + +const LoginOptions: React.FC<{ + onSubmitClick: (d: { email: string; password: string }) => void; + onSignInWithGoogle: () => void; + onSignInWithGithub: () => void; + onSignInWithAzure: () => void; + setSignUpFlow: (value: boolean) => void; + errorMessages: string[]; + loading: 'google' | 'github' | 'azure' | 'email' | null; + setErrorMessages: (value: string[]) => void; + signUpFlow: boolean; +}> = ({ + onSubmitClick, + onSignInWithGoogle, + onSignInWithGithub, + onSignInWithAzure, + setSignUpFlow, + errorMessages, + loading, + setErrorMessages, + signUpFlow, +}) => { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [password2, setPassword2] = useState(''); + const [passwordCheck, setPasswordCheck] = useState(false); + const disableSubmitButton = + !inputHasText(password) || !inputHasText(password2) || password !== password2 || !passwordCheck; + + const clearAllCookies = useMemoizedFn(() => { + for (const cookieName of Object.keys(Cookies.get())) { + Cookies.remove(cookieName); + } + + //also clear local storage + localStorage.clear(); + sessionStorage.clear(); + }); + + const onSubmitClickPreflight = useMemoizedFn(async (d: { email: string; password: string }) => { + onSubmitClick(d); + }); + + useHotkeys( + 'meta+shift+b', + (e) => { + setSignUpFlow(false); + onSubmitClickPreflight({ + email: DEFAULT_CREDENTIALS.email, + password: DEFAULT_CREDENTIALS.password, + }); + }, + { preventDefault: true }, + ); + + return ( + <> +
+ +
+ +
+ + + +
+ +
{ + v.preventDefault(); + clearAllCookies(); + onSubmitClick({ + email, + password, + }); + }} + > +
+ + { + setEmail(v.target.value); + }} + disabled={!!loading} + autoComplete="email" + /> + +
+ { + setPassword(v.target.value); + }} + disabled={!!loading} + id="password" + type="password" + name="password" + placeholder="Password" + autoComplete="new-password" + /> +
+ {signUpFlow && ( + <> + { + setPassword2(v.target.value); + }} + disabled={!!loading} + id="password2" + type="password" + name="password2" + placeholder="Confirm password" + autoComplete="new-password" + /> + + {password && ( + + )} + + )} + +
+ {errorMessages.map((message, index) => ( + + ))} +
+ + + + +
+ + + {!signUpFlow && } +
+ + ); +}; + +const SignUpSuccess: React.FC<{ + setSignUpSuccess: (value: boolean) => void; + setSignUpFlow: (value: boolean) => void; +}> = ({ setSignUpSuccess, setSignUpFlow }) => { + return ( + { + setSignUpSuccess(false); + setSignUpFlow(true); + }} + > + Go to Login + , + ]} + /> + ); +}; + +const WelcomeText: React.FC<{ + signUpFlow: boolean; +}> = ({ signUpFlow }) => { + const text = !signUpFlow ? 'Sign in' : 'Sign up for free'; + + return ( + + {text} + + ); +}; + +const LoginAlertMessage: React.FC<{ + message: string; +}> = ({ message }) => { + return ( + + {message} + + ); +}; + +const AlreadyHaveAccount: React.FC<{ + setErrorMessages: (value: string[]) => void; + setPassword2: (value: string) => void; + setSignUpFlow: (value: boolean) => void; + signUpFlow: boolean; +}> = React.memo(({ setErrorMessages, setPassword2, setSignUpFlow, signUpFlow }) => { + return ( +
+ + {signUpFlow ? 'Already have an account? ' : "Don't already have an account?"} + + + { + setErrorMessages([]); + setPassword2(''); + setSignUpFlow(!signUpFlow); + }} + > + {!signUpFlow ? 'Sign up' : 'Sign in'} + +
+ ); +}); +AlreadyHaveAccount.displayName = 'AlreadyHaveAccount'; + +const ResetPasswordLink: React.FC<{ email: string; tabIndex?: number }> = ({ email, tabIndex }) => { + const scrubbedEmail = useMemo(() => { + if (!email || !isValidEmail(email)) return ''; + try { + return encodeURIComponent(email.trim()); + } catch (error) { + console.error('Error encoding email:', error); + return ''; + } + }, [email]); + + return ( + + + Reset password + + + ); +};