feat: add user signup if configured from env var

This commit is contained in:
Nicola Zambello 2023-02-21 19:06:00 +01:00
parent ac7f544512
commit 6e1a8e0c56
Signed by: nzambello
GPG key ID: 56E4A92C2C1E50BA
7 changed files with 361 additions and 23 deletions

View file

@ -1,2 +1,3 @@
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/timer" DATABASE_URL="postgresql://postgres:postgres@localhost:5432/timer"
SESSION_SECRET="za1W297qRgKq6PNtm5EXJlOfIto6WTS" SESSION_SECRET="za1W297qRgKq6PNtm5EXJlOfIto6WTS"
ALLOW_USER_SIGNUP=1 # remove this line to disable user signup

8
app/config.server.ts Normal file
View file

@ -0,0 +1,8 @@
export const ALLOW_USER_SIGNUP = Boolean(
process.env.ALLOW_USER_SIGNUP || false
);
export const isSignupAllowed = () => {
console.log('ALLOW_USER_SIGNUP', ALLOW_USER_SIGNUP);
return !!ALLOW_USER_SIGNUP;
};

View file

@ -51,7 +51,6 @@ export async function verifyLogin(
password, password,
userWithPassword.password.hash userWithPassword.password.hash
); );
console.log(isValid, password, userWithPassword.password.hash);
if (!isValid) { if (!isValid) {
return null; return null;

View file

@ -435,7 +435,7 @@ function Layout({ children }: React.PropsWithChildren<{}>) {
component={Link} component={Link}
to="/login" to="/login"
> >
Login Sign in
</Button> </Button>
)} )}
</div> </div>

View file

@ -1,25 +1,34 @@
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, useActionData, useSearchParams } from '@remix-run/react'; import {
Form,
Link,
useActionData,
useLoaderData,
useSearchParams
} from '@remix-run/react';
import * as React from 'react'; import * as React from 'react';
import { import {
TextInput, TextInput,
Box, Box,
Checkbox,
Group, Group,
Button, Button,
PasswordInput PasswordInput,
Title
} from '@mantine/core'; } from '@mantine/core';
import { AtSign, Lock } from 'react-feather'; 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';
import { isSignupAllowed } from '~/config.server';
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('/time-entries'); if (userId) return redirect('/time-entries');
return json({});
return json({
ALLOW_USER_SIGNUP: isSignupAllowed()
});
} }
export async function action({ request }: ActionArgs) { export async function action({ request }: ActionArgs) {
@ -54,7 +63,12 @@ export async function action({ request }: ActionArgs) {
if (!user) { if (!user) {
return json( return json(
{ errors: { email: 'Invalid email or password', password: null } }, {
errors: {
email: 'Invalid email or password',
password: 'Invalid email or password'
}
},
{ status: 400 } { status: 400 }
); );
} }
@ -79,6 +93,7 @@ export default function LoginPage() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const redirectTo = searchParams.get('redirectTo') || '/time-entries'; const redirectTo = searchParams.get('redirectTo') || '/time-entries';
const actionData = useActionData<typeof action>(); const actionData = useActionData<typeof action>();
const loaderData = useLoaderData<typeof loader>();
const emailRef = React.useRef<HTMLInputElement>(null); const emailRef = React.useRef<HTMLInputElement>(null);
const passwordRef = React.useRef<HTMLInputElement>(null); const passwordRef = React.useRef<HTMLInputElement>(null);
@ -92,21 +107,9 @@ export default function LoginPage() {
return ( return (
<Box sx={{ maxWidth: 300 }} mx="auto"> <Box sx={{ maxWidth: 300 }} mx="auto">
<h1 <Title order={2} my="lg">
style={{
position: 'absolute',
width: '1px',
height: '1px',
padding: 0,
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0,0,0,0)',
whiteSpace: 'nowrap',
border: 0
}}
>
Login Login
</h1> </Title>
<Form method="post" noValidate> <Form method="post" noValidate>
<TextInput <TextInput
mb={12} mb={12}
@ -144,9 +147,28 @@ export default function LoginPage() {
<input type="hidden" name="redirectTo" value={redirectTo} /> <input type="hidden" name="redirectTo" value={redirectTo} />
<Group position="center" mt="md"> <Group position="center" mt="xl">
<Button type="submit">Log In</Button> <Button type="submit">Log In</Button>
</Group> </Group>
{!!loaderData?.ALLOW_USER_SIGNUP && (
<Box
mt="md"
sx={{
textAlign: 'center'
}}
>
New user?{' '}
<Link
to={{
pathname: '/signup',
search: searchParams.toString()
}}
>
<strong>Sign up</strong>
</Link>
</Box>
)}
</Form> </Form>
</Box> </Box>
); );

308
app/routes/signup.tsx Normal file
View file

@ -0,0 +1,308 @@
import type { ActionArgs, LoaderArgs, MetaFunction } from '@remix-run/node';
import { json, redirect } from '@remix-run/node';
import { Form, Link, useActionData, useSearchParams } from '@remix-run/react';
import * as React from 'react';
import {
TextInput,
Box,
Group,
Button,
PasswordInput,
Text,
Title,
Popover,
Progress
} from '@mantine/core';
import { AtSign, Check, Lock, X } from 'react-feather';
import { createUserSession, getUserId } from '~/session.server';
import { createUser, getUserByEmail } from '~/models/user.server';
import { safeRedirect, validateEmail } from '~/utils';
import { isSignupAllowed } from '~/config.server';
export async function loader({ request }: LoaderArgs) {
const userId = await getUserId(request);
if (userId) return redirect('/time-entries');
if (!isSignupAllowed()) {
return redirect('/login');
}
return json({});
}
export async function action({ request }: ActionArgs) {
if (!isSignupAllowed()) {
return json(
{
errors: {
email: 'User signup is disabled',
password: null,
confirmPassword: null
}
},
{ status: 418 }
);
}
const formData = await request.formData();
const email = formData.get('email');
const password = formData.get('password');
const confirmPassword = formData.get('confirmPassword');
const redirectTo = safeRedirect(formData.get('redirectTo'), '/');
if (!validateEmail(email)) {
return json(
{
errors: {
email: 'Email is invalid',
password: null,
confirmPassword: null
}
},
{ status: 400 }
);
}
if (typeof password !== 'string' || password.length === 0) {
return json(
{
errors: {
email: null,
password: 'Password is required',
confirmPassword: null
}
},
{ status: 400 }
);
}
if (password.length < 8) {
return json(
{
errors: {
email: null,
password: 'Password is too short',
confirmPassword: null
}
},
{ status: 400 }
);
}
if (password !== confirmPassword) {
return json(
{
errors: {
email: null,
password: 'Passwords do not match',
confirmPassword: 'Passwords do not match'
}
},
{ status: 400 }
);
}
const existingUser = await getUserByEmail(email);
if (existingUser) {
return json(
{
errors: {
email: 'A user already exists with this email',
password: null,
confirmPassword: null
}
},
{ status: 400 }
);
}
const user = await createUser(email, password);
return createUserSession({
request,
userId: user.id,
remember: false,
redirectTo
});
}
export const meta: MetaFunction = () => {
return {
title: 'Sign Up | WorkTimer',
description:
'WorkTimer is a time tracking app. Helps you track your time spent on projects.'
};
};
function PasswordRequirement({
meets,
label
}: {
meets: boolean;
label: string;
}) {
return (
<Text
color={meets ? 'teal' : 'red'}
sx={{ display: 'flex', alignItems: 'center' }}
mt={7}
size="sm"
>
{meets ? <Check size={14} /> : <X size={14} />} <Box ml={10}>{label}</Box>
</Text>
);
}
const requirements = [
{ re: /[0-9]/, label: 'Includes number' },
{ re: /[a-z]/, label: 'Includes lowercase letter' },
{ re: /[A-Z]/, label: 'Includes uppercase letter' },
{ re: /[$&+,:;=?@#|'<>.^*()%!-]/, label: 'Includes special symbol' }
];
function getStrength(password: string) {
let multiplier = password.length > 7 ? 0 : 1;
requirements.forEach((requirement) => {
if (!requirement.re.test(password)) {
multiplier += 1;
}
});
return Math.max(100 - (100 / (requirements.length + 1)) * multiplier, 10);
}
export default function SignUpPage() {
const [searchParams] = useSearchParams();
const redirectTo = searchParams.get('redirectTo') ?? undefined;
const actionData = useActionData<typeof action>();
const emailRef = React.useRef<HTMLInputElement>(null);
const passwordRef = React.useRef<HTMLInputElement>(null);
const [popoverOpened, setPopoverOpened] = React.useState(false);
const [password, setPassword] = React.useState('');
const checks = requirements.map((requirement, index) => (
<PasswordRequirement
key={index}
label={requirement.label}
meets={requirement.re.test(password)}
/>
));
const strength = getStrength(password);
const color = strength === 100 ? 'teal' : strength > 50 ? 'yellow' : 'red';
React.useEffect(() => {
if (actionData?.errors?.email) {
emailRef.current?.focus();
} else if (actionData?.errors?.password) {
passwordRef.current?.focus();
}
}, [actionData]);
return (
<Box sx={{ maxWidth: 300 }} mx="auto">
<Title order={2} my="lg">
Sign up
</Title>
<Form method="post" noValidate>
<TextInput
mb={12}
withAsterisk
label="Email address"
placeholder="your@email.com"
icon={<AtSign size={16} />}
id="email"
ref={emailRef}
required
autoFocus={true}
name="email"
type="email"
autoComplete="email"
aria-invalid={actionData?.errors?.email ? true : undefined}
error={actionData?.errors?.email}
errorProps={{ children: actionData?.errors?.email }}
/>
<Popover
opened={popoverOpened}
position="bottom"
width="target"
transition="pop"
>
<Popover.Target>
<div
onFocusCapture={() => setPopoverOpened(true)}
onBlurCapture={() => setPopoverOpened(false)}
>
<PasswordInput
mb={12}
withAsterisk
label="Password"
id="password"
ref={passwordRef}
placeholder="********"
icon={<Lock size={16} />}
name="password"
type="password"
autoComplete="new-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
aria-invalid={actionData?.errors?.password ? true : undefined}
error={actionData?.errors?.password ? true : undefined}
errorProps={{ children: actionData?.errors?.password }}
/>
</div>
</Popover.Target>
<Popover.Dropdown>
<Progress
color={color}
value={strength}
size={5}
style={{ marginBottom: 10 }}
/>
<PasswordRequirement
label="Includes at least 8 characters"
meets={password.length > 7}
/>
{checks}
</Popover.Dropdown>
</Popover>
<PasswordInput
mb={12}
withAsterisk
label="Confirm password"
id="confirm-password"
ref={passwordRef}
placeholder="********"
icon={<Lock size={16} />}
name="confirmPassword"
type="password"
autoComplete="new-password"
aria-invalid={actionData?.errors?.confirmPassword ? true : undefined}
error={actionData?.errors?.confirmPassword ? true : undefined}
errorProps={{ children: actionData?.errors?.confirmPassword }}
/>
<input type="hidden" name="redirectTo" value={redirectTo} />
<Group position="center" mt="xl">
<Button type="submit">Create account</Button>
</Group>
<Box
mt="md"
sx={{
textAlign: 'center'
}}
>
Already have an account?{' '}
<Link to="/login">
<strong>Log in</strong>
</Link>
</Box>
</Form>
</Box>
);
}