From 6e1a8e0c56edf6dfb95b06fc834934e190c123b7 Mon Sep 17 00:00:00 2001 From: nzambello Date: Tue, 21 Feb 2023 19:06:00 +0100 Subject: [PATCH] feat: add user signup if configured from env var --- .env.example | 1 + app/components/PasswordStrengthIndicator.tsx | 0 app/config.server.ts | 8 + app/models/user.server.ts | 1 - app/root.tsx | 2 +- app/routes/login.tsx | 64 ++-- app/routes/signup.tsx | 308 +++++++++++++++++++ 7 files changed, 361 insertions(+), 23 deletions(-) create mode 100644 app/components/PasswordStrengthIndicator.tsx create mode 100644 app/config.server.ts create mode 100644 app/routes/signup.tsx diff --git a/.env.example b/.env.example index f12ad93..f855ef0 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/timer" SESSION_SECRET="za1W297qRgKq6PNtm5EXJlOfIto6WTS" +ALLOW_USER_SIGNUP=1 # remove this line to disable user signup diff --git a/app/components/PasswordStrengthIndicator.tsx b/app/components/PasswordStrengthIndicator.tsx new file mode 100644 index 0000000..e69de29 diff --git a/app/config.server.ts b/app/config.server.ts new file mode 100644 index 0000000..2f2102a --- /dev/null +++ b/app/config.server.ts @@ -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; +}; diff --git a/app/models/user.server.ts b/app/models/user.server.ts index d7a7797..64e8318 100644 --- a/app/models/user.server.ts +++ b/app/models/user.server.ts @@ -51,7 +51,6 @@ export async function verifyLogin( password, userWithPassword.password.hash ); - console.log(isValid, password, userWithPassword.password.hash); if (!isValid) { return null; diff --git a/app/root.tsx b/app/root.tsx index 6019c59..fb80b41 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -435,7 +435,7 @@ function Layout({ children }: React.PropsWithChildren<{}>) { component={Link} to="/login" > - Login + Sign in )} diff --git a/app/routes/login.tsx b/app/routes/login.tsx index afca008..5ff4bab 100644 --- a/app/routes/login.tsx +++ b/app/routes/login.tsx @@ -1,25 +1,34 @@ import type { ActionArgs, LoaderArgs, MetaFunction } 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 { TextInput, Box, - Checkbox, Group, Button, - PasswordInput + PasswordInput, + Title } from '@mantine/core'; import { AtSign, Lock } from 'react-feather'; - import { verifyLogin } from '~/models/user.server'; import { createUserSession, getUserId } from '~/session.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'); - return json({}); + + return json({ + ALLOW_USER_SIGNUP: isSignupAllowed() + }); } export async function action({ request }: ActionArgs) { @@ -54,7 +63,12 @@ export async function action({ request }: ActionArgs) { if (!user) { return json( - { errors: { email: 'Invalid email or password', password: null } }, + { + errors: { + email: 'Invalid email or password', + password: 'Invalid email or password' + } + }, { status: 400 } ); } @@ -79,6 +93,7 @@ export default function LoginPage() { const [searchParams] = useSearchParams(); const redirectTo = searchParams.get('redirectTo') || '/time-entries'; const actionData = useActionData(); + const loaderData = useLoaderData(); const emailRef = React.useRef(null); const passwordRef = React.useRef(null); @@ -92,21 +107,9 @@ export default function LoginPage() { return ( -

+ Login - </h1> +
- + + + {!!loaderData?.ALLOW_USER_SIGNUP && ( + + New user?{' '} + + Sign up + + + )} ); diff --git a/app/routes/signup.tsx b/app/routes/signup.tsx new file mode 100644 index 0000000..0fe859f --- /dev/null +++ b/app/routes/signup.tsx @@ -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 ( + + {meets ? : } {label} + + ); +} + +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(); + const emailRef = React.useRef(null); + const passwordRef = React.useRef(null); + + const [popoverOpened, setPopoverOpened] = React.useState(false); + const [password, setPassword] = React.useState(''); + const checks = requirements.map((requirement, index) => ( + + )); + + 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 ( + + + Sign up + +
+ } + 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 }} + /> + + + +
setPopoverOpened(true)} + onBlurCapture={() => setPopoverOpened(false)} + > + } + 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 }} + /> +
+
+ + + 7} + /> + {checks} + +
+ + } + 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 }} + /> + + + + + + + + + Already have an account?{' '} + + Log in + + + +
+ ); +}