feat: add login page

This commit is contained in:
Nicola Zambello 2023-02-14 10:15:44 +01:00
parent 3946d0f05c
commit b717c55643
Signed by: nzambello
GPG key ID: 56E4A92C2C1E50BA

View file

@ -1,41 +1,62 @@
import type { ActionArgs, LoaderArgs, MetaFunction } from '@remix-run/node' import type { ActionArgs, LoaderArgs, MetaFunction } from '@remix-run/node';
import { json, redirect } from '@remix-run/node' import { json, redirect } from '@remix-run/node';
import { Form, Link, useActionData, useSearchParams } from '@remix-run/react' import { Form, useActionData, useSearchParams } from '@remix-run/react';
import * as React from 'react' import * as React from 'react';
import {
TextInput,
Box,
Checkbox,
Group,
Button,
PasswordInput
} from '@mantine/core';
import { AtSign, Lock } from 'react-feather';
import { verifyLogin } from '~/models/user.server' import { verifyLogin } from '~/models/user.server';
import { createUserSession, getUserId } from '~/session.server' import { createUserSession, getUserId } from '~/session.server';
import { safeRedirect, validateEmail } from '~/utils' import { safeRedirect, validateEmail } from '~/utils';
export async function loader({ request }: LoaderArgs) { export async function loader({ request }: LoaderArgs) {
const userId = await getUserId(request) const userId = await getUserId(request);
if (userId) return redirect('/') if (userId) return redirect('/time-entries');
return json({}) return json({});
} }
export async function action({ request }: ActionArgs) { export async function action({ request }: ActionArgs) {
const formData = await request.formData() const formData = await request.formData();
const email = formData.get('email') const email = formData.get('email');
const password = formData.get('password') const password = formData.get('password');
const redirectTo = safeRedirect(formData.get('redirectTo'), '/') const redirectTo = safeRedirect(formData.get('redirectTo'), '/');
const remember = formData.get('remember') const remember = formData.get('remember');
if (!validateEmail(email)) { if (!validateEmail(email)) {
return json({ errors: { email: 'Email is invalid', password: null } }, { status: 400 }) return json(
{ errors: { email: 'Email is invalid', password: null } },
{ status: 400 }
);
} }
if (typeof password !== 'string' || password.length === 0) { if (typeof password !== 'string' || password.length === 0) {
return json({ errors: { password: 'Password is required', email: null } }, { status: 400 }) return json(
{ errors: { password: 'Password is required', email: null } },
{ status: 400 }
);
} }
if (password.length < 8) { if (password.length < 8) {
return json({ errors: { password: 'Password is too short', email: null } }, { status: 400 }) return json(
{ errors: { password: 'Password is too short', email: null } },
{ status: 400 }
);
} }
const user = await verifyLogin(email, password) const user = await verifyLogin(email, password);
if (!user) { if (!user) {
return json({ errors: { email: 'Invalid email or password', password: null } }, { status: 400 }) return json(
{ errors: { email: 'Invalid email or password', password: null } },
{ status: 400 }
);
} }
return createUserSession({ return createUserSession({
@ -43,104 +64,75 @@ export async function action({ request }: ActionArgs) {
userId: user.id, userId: user.id,
remember: remember === 'on' ? true : false, remember: remember === 'on' ? true : false,
redirectTo redirectTo
}) });
} }
export const meta: MetaFunction = () => { export const meta: MetaFunction = () => {
return { return {
title: 'Login' title: 'Login | WorkTimer',
} description:
} 'WorkTimer is a time tracking app. Helps you track your time spent on projects.'
};
};
export default function LoginPage() { export default function LoginPage() {
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams();
const redirectTo = searchParams.get('redirectTo') || '/notes' const redirectTo = searchParams.get('redirectTo') || '/time-entries';
const actionData = useActionData<typeof action>() const actionData = useActionData<typeof action>();
const emailRef = React.useRef<HTMLInputElement>(null) const emailRef = React.useRef<HTMLInputElement>(null);
const passwordRef = React.useRef<HTMLInputElement>(null) const passwordRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => { React.useEffect(() => {
if (actionData?.errors?.email) { if (actionData?.errors?.email) {
emailRef.current?.focus() emailRef.current?.focus();
} else if (actionData?.errors?.password) { } else if (actionData?.errors?.password) {
passwordRef.current?.focus() passwordRef.current?.focus();
} }
}, [actionData]) }, [actionData]);
return ( return (
<div className="flex min-h-full flex-col justify-center"> <Box sx={{ maxWidth: 300 }} mx="auto">
<div className="mx-auto w-full max-w-md px-8"> <Form method="post" noValidate>
<Form method="post" className="space-y-6" noValidate> <TextInput
<div> mb={12}
<label htmlFor="email" className="block text-sm font-medium text-gray-700"> withAsterisk
Email address label="Email address"
</label> placeholder="your@email.com"
<div className="mt-1"> icon={<AtSign size={16} />}
<input
ref={emailRef}
id="email" id="email"
ref={emailRef}
required required
autoFocus={true} autoFocus={true}
name="email" name="email"
type="email" type="email"
autoComplete="email" autoComplete="email"
aria-invalid={actionData?.errors?.email ? true : undefined} aria-invalid={actionData?.errors?.email ? true : undefined}
aria-describedby="email-error" error={actionData?.errors?.email}
className="w-full rounded border border-gray-500 px-2 py-1 text-lg" errorProps={{ children: actionData?.errors?.email }}
/> />
{actionData?.errors?.email && (
<div className="pt-1 text-red-700" id="email-error">
{actionData.errors.email}
</div>
)}
</div>
</div>
<div> <PasswordInput
<label htmlFor="password" className="block text-sm font-medium text-gray-700"> mb={12}
Password withAsterisk
</label> label="Password"
<div className="mt-1">
<input
id="password" id="password"
ref={passwordRef} ref={passwordRef}
placeholder="********"
icon={<Lock size={16} />}
name="password" name="password"
type="password" type="password"
autoComplete="current-password" autoComplete="current-password"
aria-invalid={actionData?.errors?.password ? true : undefined} aria-invalid={actionData?.errors?.password ? true : undefined}
aria-describedby="password-error" error={actionData?.errors?.password ? true : undefined}
className="w-full rounded border border-gray-500 px-2 py-1 text-lg" errorProps={{ children: actionData?.errors?.password }}
/> />
{actionData?.errors?.password && (
<div className="pt-1 text-red-700" id="password-error">
{actionData.errors.password}
</div>
)}
</div>
</div>
<input type="hidden" name="redirectTo" value={redirectTo} /> <input type="hidden" name="redirectTo" value={redirectTo} />
<button
type="submit" <Group position="center" mt="md">
className="w-full rounded bg-blue-500 py-2 px-4 text-white hover:bg-blue-600 focus:bg-blue-400" <Button type="submit">Log In</Button>
> </Group>
Log in
</button>
<div className="flex items-center justify-between">
<div className="flex items-center">
<input
id="remember"
name="remember"
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<label htmlFor="remember" className="ml-2 block text-sm text-gray-900">
Remember me
</label>
</div>
</div>
</Form> </Form>
</div> </Box>
</div> );
)
} }