170 lines
5.3 KiB
TypeScript
170 lines
5.3 KiB
TypeScript
import { Box, Container, Flex, Heading } from '@radix-ui/themes';
|
|
import { useMutation } from '@tanstack/react-query';
|
|
import { useLocation, useNavigate } from 'react-router';
|
|
import { Slide, toast, ToastContainer } from 'react-toastify';
|
|
import * as v from 'valibot';
|
|
import { useResponseErrorHandler } from '../../hooks/ResponseHelper';
|
|
import { useApi } from '../../providers/ApiProvider';
|
|
import { formHook } from '../../providers/FormProvider';
|
|
import type { Route } from './+types/login';
|
|
import { SearchParamKeys } from '../../lib/constants';
|
|
import { useEffect, useState } from 'react';
|
|
|
|
const loginFormSchema = v.object({
|
|
username: v.pipe(v.string(), v.trim(), v.minLength(1, 'Username is required')),
|
|
password: v.pipe(v.string(), v.minLength(1, 'Password is required')),
|
|
});
|
|
|
|
// eslint-disable-next-line no-empty-pattern
|
|
export function meta({}: Route.MetaArgs): Route.MetaDescriptors {
|
|
return [{ title: 'Login | YANPM' }];
|
|
}
|
|
|
|
// TODO: remember me
|
|
export default function LoginRoute() {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const { tanstackApiClient } = useApi();
|
|
const { defaultResponseErrorHandler } = useResponseErrorHandler();
|
|
const [previousSearchParamMessage, setPreviousSearchParamMessage] = useState<string>('');
|
|
|
|
const { mutateAsync: login, isPending } = useMutation({
|
|
...tanstackApiClient.mutation('post', '/api/auth/login').mutationOptions,
|
|
onSuccess: async () => {
|
|
const searchParams = new URLSearchParams(location.search);
|
|
const redirectTo = searchParams.get(SearchParamKeys.Redirect);
|
|
if (redirectTo) {
|
|
navigate(redirectTo);
|
|
return;
|
|
}
|
|
navigate('/');
|
|
},
|
|
onError: (error) => {
|
|
if (defaultResponseErrorHandler(error)) return;
|
|
console.error('Login failed:', error);
|
|
},
|
|
});
|
|
|
|
const form = formHook.useAppForm({
|
|
defaultValues: {
|
|
username: '',
|
|
password: '',
|
|
},
|
|
validators: {
|
|
onBlur: loginFormSchema,
|
|
onSubmit: loginFormSchema,
|
|
},
|
|
|
|
onSubmit: async ({ value }) => {
|
|
toast.dismiss();
|
|
return await login({ body: { password: value.password, username: value.username } });
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
const searchParams = new URLSearchParams(location.search);
|
|
const message = searchParams.get(SearchParamKeys.Message);
|
|
if (message && message !== previousSearchParamMessage) {
|
|
setPreviousSearchParamMessage(message);
|
|
toast.info(message, {
|
|
position: 'top-center',
|
|
autoClose: 5000,
|
|
hideProgressBar: false,
|
|
closeOnClick: true,
|
|
pauseOnHover: true,
|
|
draggable: false,
|
|
progress: undefined,
|
|
theme: 'colored',
|
|
toastId: 'login-route-info-message',
|
|
});
|
|
}
|
|
}, [location.search]);
|
|
|
|
return (
|
|
<>
|
|
<Flex align="center" justify="center" style={{ minHeight: 'calc(100vh - 64px)' }}>
|
|
<Container size="3" p="0">
|
|
<Box
|
|
style={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
maxWidth: 420,
|
|
margin: '40px auto',
|
|
backgroundColor: 'white',
|
|
padding: 24,
|
|
borderRadius: 8,
|
|
boxShadow: '0 6px 18px rgba(15,23,42,0.2)',
|
|
}}
|
|
>
|
|
<Heading size="6" style={{ marginBottom: 16, alignSelf: 'center' }}>
|
|
Sign In
|
|
</Heading>
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
form.handleSubmit();
|
|
}}
|
|
>
|
|
<form.AppField
|
|
name="username"
|
|
children={(field) => (
|
|
<>
|
|
<field.TextField
|
|
label={'Username'}
|
|
value={field.state.value}
|
|
autoComplete="username"
|
|
spellCheck={false}
|
|
required
|
|
onChange={(e) => field.handleChange(e.target.value)}
|
|
/>
|
|
<field.TextFieldErrorMessage {...field.state.meta} />
|
|
</>
|
|
)}
|
|
/>
|
|
|
|
<form.AppField
|
|
name="password"
|
|
children={(field) => (
|
|
<>
|
|
<field.TextField
|
|
label={'Password'}
|
|
value={field.state.value}
|
|
type="password"
|
|
required
|
|
autoComplete="current-password"
|
|
onChange={(e) => field.handleChange(e.target.value)}
|
|
showPasswordToggle
|
|
/>
|
|
<field.TextFieldErrorMessage {...field.state.meta} />
|
|
</>
|
|
)}
|
|
/>
|
|
|
|
<div style={{ marginTop: 18, display: 'flex', gap: 8, justifySelf: 'center' }}>
|
|
<form.SubmitButton
|
|
loading={isPending}
|
|
label={{
|
|
default: 'Sign In',
|
|
loading: 'Signing In…',
|
|
}}
|
|
/>
|
|
</div>
|
|
</form>
|
|
</Box>
|
|
</Container>
|
|
</Flex>
|
|
<ToastContainer
|
|
position="top-center"
|
|
autoClose={false}
|
|
newestOnTop={false}
|
|
closeOnClick
|
|
rtl={false}
|
|
pauseOnFocusLoss
|
|
draggable={false}
|
|
theme="colored"
|
|
transition={Slide}
|
|
/>
|
|
</>
|
|
);
|
|
}
|