buster/web/src/components/features/auth/LoginForm.tsx

441 lines
12 KiB
TypeScript
Raw Normal View History

'use client';
2025-01-25 02:04:34 +08:00
import React, { useMemo, useState } from 'react';
2025-03-05 02:13:01 +08:00
import { Button } from '@/components/ui/buttons';
import { Input } from '@/components/ui/inputs';
import { Title, Text } from '@/components/ui/typography';
import { inputHasText } from '@/lib/text';
import { isValidEmail } from '@/lib/email';
2025-03-08 07:02:56 +08:00
import { useMemoizedFn } from '@/hooks';
import Link from 'next/link';
import { BusterRoutes, createBusterRoute } from '@/routes/busterRoutes';
2025-03-08 07:02:56 +08:00
import Google from '@/components/ui/icons/customIcons/Google';
import Microsoft from '@/components/ui/icons/customIcons/Microsoft';
import Github from '@/components/ui/icons/customIcons/Github';
import Cookies from 'js-cookie';
import { PolicyCheck } from './PolicyCheck';
import { rustErrorHandler } from '@/api/buster_rest/errors';
2025-03-05 02:13:01 +08:00
import { cn } from '@/lib/classMerge';
import { SuccessCard } from '@/components/ui/card/SuccessCard';
import { useHotkeys } from 'react-hotkeys-hook';
2025-03-05 02:44:32 +08:00
import {
signInWithAzure,
signInWithEmailAndPassword,
signInWithGithub,
signInWithGoogle,
signUp
} from '@/lib/supabase/signIn';
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<string[]>([]);
const [signUpFlow, setSignUpFlow] = useState(true);
const [signUpSuccess, setSignUpSuccess] = useState(false);
const errorFallback = (error: any) => {
const errorMessage = rustErrorHandler(error);
if (errorMessage?.message) {
setErrorMessages(['Invalid email or password']);
} else {
setErrorMessages(['An error occurred']);
}
};
const onSignInWithUsernameAndPassword = useMemoizedFn(
async ({ email, password }: { email: string; password: string }) => {
setLoading('email');
try {
const res = await signInWithEmailAndPassword({ email, password });
if (res?.error) throw res.error;
} catch (error: any) {
errorFallback(error);
}
setLoading(null);
}
);
const onSignInWithGoogle = useMemoizedFn(async () => {
setLoading('google');
try {
const res = await signInWithGoogle();
if (res?.error) throw res.error;
} catch (error: any) {
errorFallback(error);
}
2025-04-18 03:51:19 +08:00
setLoading(null);
});
const onSignInWithGithub = useMemoizedFn(async () => {
setLoading('github');
try {
const res = await signInWithGithub();
if (res?.error) throw res.error;
} catch (error: any) {
errorFallback(error);
}
2025-04-18 03:51:19 +08:00
setLoading(null);
});
const onSignInWithAzure = useMemoizedFn(async () => {
setLoading('azure');
try {
const res = await signInWithAzure();
if (res?.error) throw res.error;
} catch (error: any) {
errorFallback(error);
}
setLoading('azure');
});
const onSignUp = useMemoizedFn(async (d: { email: string; password: string }) => {
setLoading('email');
try {
const res = await signUp(d);
if (res?.error) throw res.error;
setSignUpSuccess(true);
} catch (error: any) {
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: any) {
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 (
<div className="flex h-full flex-col items-center justify-center">
<div className="w-[330px]">
{signUpSuccess ? (
<SignUpSuccess setSignUpSuccess={setSignUpSuccess} setSignUpFlow={setSignUpFlow} />
) : (
<LoginOptions
onSubmitClick={onSubmitClick}
setSignUpFlow={setSignUpFlow}
errorMessages={errorMessages}
loading={loading}
setErrorMessages={setErrorMessages}
signUpFlow={signUpFlow}
onSignInWithGoogle={onSignInWithGoogle}
onSignInWithGithub={onSignInWithGithub}
onSignInWithAzure={onSignInWithAzure}
/>
)}
</div>
</div>
);
};
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(() => {
Object.keys(Cookies.get()).forEach((cookieName) => {
Cookies.remove(cookieName);
});
2025-04-18 03:51:19 +08:00
//also clear local storage
localStorage.clear();
sessionStorage.clear();
});
2025-01-25 02:04:34 +08:00
const onSubmitClickPreflight = useMemoizedFn(async (d: { email: string; password: string }) => {
clearAllCookies();
onSubmitClick(d);
2025-01-25 02:04:34 +08:00
});
2025-04-04 01:02:17 +08:00
useHotkeys(
'meta+shift+b',
(e) => {
setSignUpFlow(false);
onSubmitClickPreflight({
email: DEFAULT_CREDENTIALS.email,
password: DEFAULT_CREDENTIALS.password
});
},
{ preventDefault: true }
);
return (
<>
<div className="flex flex-col items-center text-center">
<WelcomeText signUpFlow={signUpFlow} />
</div>
<form
className="my-6 space-y-3"
onSubmit={(v) => {
v.preventDefault();
onSubmitClickPreflight({
email,
password
});
}}>
<Button
2025-03-08 07:02:56 +08:00
prefix={<Google />}
2025-04-18 00:47:37 +08:00
size={'tall'}
onClick={() => {
clearAllCookies();
onSignInWithGoogle();
}}
block={true}
loading={loading === 'google'}>
{!signUpFlow ? `Continue with Google` : `Sign up with Google`}
</Button>
<Button
2025-03-08 07:02:56 +08:00
prefix={<Github />}
2025-04-18 00:47:37 +08:00
size={'tall'}
onClick={() => {
clearAllCookies();
onSignInWithGithub();
}}
block={true}
loading={loading === 'github'}>
{!signUpFlow ? `Continue with Github` : `Sign up with Github`}
</Button>
<Button
2025-03-08 07:02:56 +08:00
prefix={<Microsoft />}
2025-04-18 00:47:37 +08:00
size={'tall'}
onClick={() => {
clearAllCookies();
onSignInWithAzure();
}}
block={true}
loading={loading === 'azure'}>
{!signUpFlow ? `Continue with Azure` : `Sign up with Azure`}
</Button>
2025-03-05 02:13:01 +08:00
<div className="bg-border h-[0.5px] w-full" />
<Input
type="email"
placeholder="What is your email address?"
name="email"
id="email"
value={email}
onChange={(v) => {
setEmail(v.target.value);
}}
disabled={!!loading}
autoComplete="email"
/>
<div className="relative">
<Input
value={password}
onChange={(v) => {
setPassword(v.target.value);
}}
disabled={!!loading}
id="password"
type="password"
name="password"
placeholder="Password"
autoComplete="new-password"
/>
{signUpFlow && (
<div className="absolute top-0 right-1.5 flex h-full items-center">
<PolicyCheck password={password} show={signUpFlow} onCheckChange={setPasswordCheck} />
</div>
)}
</div>
{signUpFlow && (
<Input
value={password2}
onChange={(v) => {
setPassword2(v.target.value);
}}
disabled={!!loading}
id="password2"
type="password"
name="password2"
placeholder="Confirm password"
autoComplete="new-password"
/>
)}
<div className="flex flex-col space-y-0.5">
{errorMessages.map((message, index) => (
<LoginAlertMessage key={index} message={message} />
))}
</div>
<PolicyCheck
password={password}
show={signUpFlow && disableSubmitButton && !!password}
placement="top">
<Button
2025-04-18 00:47:37 +08:00
size={'tall'}
block={true}
2025-03-05 02:13:01 +08:00
type="submit"
loading={loading === 'email'}
2025-03-05 02:13:01 +08:00
variant="black"
disabled={!signUpFlow ? false : disableSubmitButton}>
{!signUpFlow ? `Sign in` : `Sign up`}
</Button>
</PolicyCheck>
</form>
<div className="flex flex-col gap-y-2 pt-0">
2025-01-25 02:04:34 +08:00
<AlreadyHaveAccount
setErrorMessages={setErrorMessages}
setPassword2={setPassword2}
setSignUpFlow={setSignUpFlow}
signUpFlow={signUpFlow}
/>
{!signUpFlow && <ResetPasswordLink email={email} />}
</div>
</>
);
};
const SignUpSuccess: React.FC<{
setSignUpSuccess: (value: boolean) => void;
setSignUpFlow: (value: boolean) => void;
}> = ({ setSignUpSuccess, setSignUpFlow }) => {
return (
2025-03-05 02:44:32 +08:00
<SuccessCard
title="Thanks for signing up"
2025-03-05 02:44:32 +08:00
message="Please check your email to verify your account."
extra={[
<Button
key="login"
2025-03-05 02:13:01 +08:00
variant="black"
2025-04-18 00:47:37 +08:00
size={'tall'}
onClick={() => {
setSignUpSuccess(false);
setSignUpFlow(true);
}}>
Go to Login
</Button>
]}
/>
);
};
const WelcomeText: React.FC<{
signUpFlow: boolean;
}> = ({ signUpFlow }) => {
const text = !signUpFlow ? `Sign in` : `Sign up for free`;
return (
2025-03-02 14:05:55 +08:00
<Title className="mb-0" as="h1">
{text}
</Title>
);
};
const LoginAlertMessage: React.FC<{
message: string;
}> = ({ message }) => {
return (
2025-03-05 03:03:21 +08:00
<Text size="xs" variant="danger">
{message}
</Text>
);
};
2025-01-25 02:04:34 +08:00
const AlreadyHaveAccount: React.FC<{
setErrorMessages: (value: string[]) => void;
setPassword2: (value: string) => void;
setSignUpFlow: (value: boolean) => void;
signUpFlow: boolean;
}> = React.memo(({ setErrorMessages, setPassword2, setSignUpFlow, signUpFlow }) => {
2025-01-25 02:04:34 +08:00
return (
2025-03-05 02:13:01 +08:00
<div className="flex items-center justify-center gap-0.5">
<Text className="" variant="secondary" size="xs">
{signUpFlow ? `Already have an account? ` : `Dont already have an account? `}
2025-01-25 02:04:34 +08:00
</Text>
2025-03-05 02:13:01 +08:00
<Text
variant="primary"
size="xs"
className={cn('ml-1 cursor-pointer font-normal')}
onClick={() => {
setErrorMessages([]);
setPassword2('');
setSignUpFlow(!signUpFlow);
}}>
{!signUpFlow ? `Sign up` : `Sign in`}
2025-03-05 02:13:01 +08:00
</Text>
</div>
2025-01-25 02:04:34 +08:00
);
});
AlreadyHaveAccount.displayName = 'AlreadyHaveAccount';
const ResetPasswordLink: React.FC<{ email: string }> = ({ email }) => {
const scrubbedEmail = useMemo(() => {
if (!email || !isValidEmail(email)) return '';
try {
return encodeURIComponent(email.trim());
} catch (error) {
console.error('Error encoding email:', error);
return '';
}
}, [email]);
return (
<Link
2025-03-05 02:13:01 +08:00
className={cn('flex w-full cursor-pointer justify-center text-center font-normal')}
2025-01-25 02:04:34 +08:00
href={
createBusterRoute({
route: BusterRoutes.AUTH_RESET_PASSWORD_EMAIL
}) + `?email=${scrubbedEmail}`
}>
<Text variant="primary" size="xs">
2025-01-25 02:04:34 +08:00
Reset password
</Text>
</Link>
);
};