diff --git a/app/models/user.server.ts b/app/models/user.server.ts index 64e8318..5a5b3bc 100644 --- a/app/models/user.server.ts +++ b/app/models/user.server.ts @@ -28,6 +28,28 @@ export async function createUser(email: User['email'], password: string) { }); } +export async function updateUserEmail(id: User['id'], email: string) { + return prisma.user.update({ + where: { id }, + data: { email } + }); +} + +export async function updateUserPassword(id: User['id'], password: string) { + const hashedPassword = await bcrypt.hash(password, 10); + + return prisma.user.update({ + where: { id }, + data: { + password: { + update: { + hash: hashedPassword + } + } + } + }); +} + export async function deleteUserByEmail(email: User['email']) { return prisma.user.delete({ where: { email } }); } diff --git a/app/root.tsx b/app/root.tsx index fb80b41..62556a1 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -34,7 +34,8 @@ import { Group, UnstyledButton, ThemeIcon, - NavLink + NavLink, + Menu } from '@mantine/core'; import { useColorScheme, useLocalStorage } from '@mantine/hooks'; import { StylesPlaceholder } from '@mantine/remix'; @@ -54,7 +55,11 @@ import { Briefcase, BarChart2, FileText, - Upload + Upload, + Settings, + Lock, + User, + Users } from 'react-feather'; import { NotificationsProvider } from '@mantine/notifications'; @@ -267,7 +272,7 @@ function Layout({ children }: React.PropsWithChildren<{}>) { }` })} > - + toggleColorScheme()} @@ -280,12 +285,13 @@ function Layout({ children }: React.PropsWithChildren<{}>) { )} + Toggle theme {user && ( - + ) { )} {user && ( -
- - - - - -
- {user.email.split('@')[0]} - - Click to logout - -
-
-
-
+ + + + + + + +
+ + {user.email.split('@')[0]} + + + {user.email.split('@')[1]} + +
+
+
+
+ + + Account + } + component={Link} + to="/account" + > + Settings + +
+ } type="submit"> + Logout + +
+
+
)} diff --git a/app/routes/account.tsx b/app/routes/account.tsx new file mode 100644 index 0000000..d766c5d --- /dev/null +++ b/app/routes/account.tsx @@ -0,0 +1,134 @@ +import type { ActionArgs, LoaderArgs, MetaFunction } from '@remix-run/node'; +import { json, redirect } from '@remix-run/node'; +import { + Form, + Link, + Outlet, + useActionData, + useLoaderData, + useSearchParams +} from '@remix-run/react'; +import * as React from 'react'; +import { + TextInput, + Box, + Group, + Button, + PasswordInput, + Text, + Title, + Popover, + Progress, + Modal +} from '@mantine/core'; +import { AtSign, Check, Lock, Save, Trash, X } from 'react-feather'; +import { requireUser } from '~/session.server'; +import { updateUserEmail } from '~/models/user.server'; +import { validateEmail } from '~/utils'; + +export async function loader({ request }: LoaderArgs) { + const user = await requireUser(request); + if (!user) return redirect('/login'); + + return json({ user }); +} + +export async function action({ request }: ActionArgs) { + const user = await requireUser(request); + const formData = await request.formData(); + const email = formData.get('email'); + + if (!validateEmail(email)) { + return json( + { + errors: { + email: 'Email is invalid' + }, + user + }, + { status: 400 } + ); + } + + await updateUserEmail(user.id, email); + + return redirect('/account/updatesuccess'); +} + +export const meta: MetaFunction = () => { + return { + title: 'Account | WorkTimer', + description: + 'Manage your account settings and change your password for WorkTimer' + }; +}; + +export default function Account() { + const actionData = useActionData(); + const loaderData = useLoaderData(); + + const emailRef = React.useRef(null); + + React.useEffect(() => { + if (actionData?.errors?.email) { + emailRef.current?.focus(); + } + }, [actionData]); + + return ( + + + Account + +
+ } + id="email" + ref={emailRef} + autoFocus={true} + defaultValue={actionData?.user?.email || loaderData?.user?.email} + name="email" + type="email" + autoComplete="off" + aria-invalid={actionData?.errors?.email ? true : undefined} + error={actionData?.errors?.email} + errorProps={{ children: actionData?.errors?.email }} + /> + + + + + + + + + + + + + + + +
+ ); +} diff --git a/app/routes/account/delete.tsx b/app/routes/account/delete.tsx new file mode 100644 index 0000000..4c72c0e --- /dev/null +++ b/app/routes/account/delete.tsx @@ -0,0 +1,133 @@ +import type { ActionArgs, LoaderArgs, MetaFunction } from '@remix-run/node'; +import { json, redirect } from '@remix-run/node'; +import { Form, useActionData, useNavigate } from '@remix-run/react'; +import * as React from 'react'; +import { + Group, + Button, + PasswordInput, + Modal, + Alert, + Text +} from '@mantine/core'; +import { AlertTriangle, Lock, Trash } from 'react-feather'; +import { requireUser } from '~/session.server'; +import { deleteUserByEmail, verifyLogin } from '~/models/user.server'; + +export async function loader({ request }: LoaderArgs) { + const user = await requireUser(request); + if (!user) return redirect('/login'); + + return json({ user }); +} + +export async function action({ request }: ActionArgs) { + const user = await requireUser(request); + const formData = await request.formData(); + const password = formData.get('password'); + + if (request.method !== 'DELETE') { + return json( + { + errors: { + password: 'Invalid request' + } + }, + { status: 422 } + ); + } + + if (typeof password !== 'string' || password.length === 0) { + return json( + { + errors: { + password: 'Password is required' + } + }, + { status: 400 } + ); + } + + const verifiedUser = await verifyLogin(user.email, password); + if (!verifiedUser) { + return json( + { + errors: { + password: 'Password is incorrect' + } + }, + { status: 400 } + ); + } + + await deleteUserByEmail(user.email); + + return redirect('/account/deleted'); +} + +export const meta: MetaFunction = () => { + return { + title: 'Account | WorkTimer', + description: + 'Manage your account settings and change your password for WorkTimer' + }; +}; + +export default function AccountDelete() { + const actionData = useActionData(); + const navigate = useNavigate(); + + const passwordRef = React.useRef(null); + + React.useEffect(() => { + if (actionData?.errors?.password) { + passwordRef.current?.focus(); + } + }, [actionData]); + + return ( + navigate('/account')} + title="Delete account" + > + } + radius="md" + mb="md" + title="Are you sure?" + > + This action cannot be undone. All of your data will be permanently + deleted. + + +
+ } + name="password" + type="password" + autoComplete="off" + aria-invalid={actionData?.errors?.password ? true : undefined} + error={actionData?.errors?.password ? true : undefined} + /> + {actionData?.errors?.password && ( + + {actionData?.errors?.password} + + )} + + + + + +
+ ); +} diff --git a/app/routes/account/deleted.tsx b/app/routes/account/deleted.tsx new file mode 100644 index 0000000..0ee0a64 --- /dev/null +++ b/app/routes/account/deleted.tsx @@ -0,0 +1,27 @@ +import { Modal, Title, Text } from '@mantine/core'; +import { useNavigate } from '@remix-run/react'; + +export default function DeletedAccount() { + const navigate = useNavigate(); + + return ( + navigate('/')} + withCloseButton + shadow="md" + radius="md" + > + Your account has been deleted + + + Sorry to see you go. If you change your mind, you can always create a + new account. + + + + Go back to the homepage + + + ); +} diff --git a/app/routes/account/updatepassword.tsx b/app/routes/account/updatepassword.tsx new file mode 100644 index 0000000..9fe70bc --- /dev/null +++ b/app/routes/account/updatepassword.tsx @@ -0,0 +1,277 @@ +import type { ActionArgs, LoaderArgs, MetaFunction } from '@remix-run/node'; +import { json, redirect } from '@remix-run/node'; +import { Form, useActionData, useNavigate } from '@remix-run/react'; +import * as React from 'react'; +import { + Box, + Group, + Button, + PasswordInput, + Text, + Popover, + Progress, + Modal +} from '@mantine/core'; +import { Check, Lock, Save, X } from 'react-feather'; +import { requireUser } from '~/session.server'; +import { updateUserPassword, verifyLogin } from '~/models/user.server'; + +export async function loader({ request }: LoaderArgs) { + const user = await requireUser(request); + if (!user) return redirect('/login'); + + return json({ user }); +} + +export async function action({ request }: ActionArgs) { + const user = await requireUser(request); + const formData = await request.formData(); + const currentPassword = formData.get('currentPassword'); + const password = formData.get('password'); + const confirmPassword = formData.get('confirmPassword'); + + if (typeof currentPassword !== 'string' || currentPassword.length === 0) { + return json( + { + errors: { + currentPassword: 'Current password is required', + password: null, + confirmPassword: null + } + }, + { status: 400 } + ); + } + + if (typeof password !== 'string' || password.length === 0) { + return json( + { + errors: { + currentPassword: null, + password: 'Password is required', + confirmPassword: null + } + }, + { status: 400 } + ); + } + + if (typeof confirmPassword !== 'string' || confirmPassword.length === 0) { + return json( + { + errors: { + currentPassword: null, + password: null, + confirmPassword: 'Confirm password is required' + } + }, + { status: 400 } + ); + } + + if (password.length < 8) { + return json( + { + errors: { + currentPassword: null, + password: 'Password is too short', + confirmPassword: null + } + }, + { status: 400 } + ); + } + + if (password !== confirmPassword) { + return json( + { + errors: { + currentPassword: null, + password: 'Passwords do not match', + confirmPassword: 'Passwords do not match' + } + }, + { status: 400 } + ); + } + + const verifiedUser = await verifyLogin(user.email, currentPassword); + + if (!verifiedUser) { + return json( + { + errors: { + currentPassword: 'Current password is incorrect', + password: null, + confirmPassword: null + } + }, + { status: 400 } + ); + } + + await updateUserPassword(user.id, password); + + return redirect('/account/updatesuccess'); +} + +export const meta: MetaFunction = () => { + return { + title: 'Account | WorkTimer', + description: + 'Manage your account settings and change your password for WorkTimer' + }; +}; + +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 AccountUpdatePassword() { + const actionData = useActionData(); + const navigate = useNavigate(); + + 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?.password) { + passwordRef.current?.focus(); + } + }, [actionData]); + + return ( + navigate('/account')} + title="Change password" + > +
+ } + name="currentPassword" + type="password" + autoComplete="off" + aria-invalid={actionData?.errors?.password ? true : undefined} + error={actionData?.errors?.password ? true : undefined} + errorProps={{ children: actionData?.errors?.password }} + /> + + + +
setPopoverOpened(true)} + onBlurCapture={() => setPopoverOpened(false)} + > + } + name="password" + type="password" + autoComplete="off" + 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="off" + aria-invalid={actionData?.errors?.confirmPassword ? true : undefined} + error={actionData?.errors?.confirmPassword ? true : undefined} + errorProps={{ children: actionData?.errors?.confirmPassword }} + /> + + + + +
+ ); +} diff --git a/app/routes/account/updatesuccess.tsx b/app/routes/account/updatesuccess.tsx new file mode 100644 index 0000000..e54fb86 --- /dev/null +++ b/app/routes/account/updatesuccess.tsx @@ -0,0 +1,26 @@ +import { Alert } from '@mantine/core'; +import { useNavigate } from '@remix-run/react'; +import { useEffect } from 'react'; +import { Check } from 'react-feather'; + +export default function UpdateSuccess() { + const navigate = useNavigate(); + + useEffect(() => { + setTimeout(() => navigate('/account'), 3000); + }); + + return ( + } + color="teal" + mt="xl" + radius="md" + withCloseButton + closeButtonLabel="Close alert" + onClose={() => navigate('/account')} + > + Account updated successfully + + ); +}