feat: add account management with update + delete

This commit is contained in:
Nicola Zambello 2023-02-23 01:20:18 +01:00
parent f51131eb00
commit 4d7e7ef167
Signed by: nzambello
GPG key ID: 56E4A92C2C1E50BA
7 changed files with 673 additions and 19 deletions

View file

@ -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']) { export async function deleteUserByEmail(email: User['email']) {
return prisma.user.delete({ where: { email } }); return prisma.user.delete({ where: { email } });
} }

View file

@ -34,7 +34,8 @@ import {
Group, Group,
UnstyledButton, UnstyledButton,
ThemeIcon, ThemeIcon,
NavLink NavLink,
Menu
} from '@mantine/core'; } from '@mantine/core';
import { useColorScheme, useLocalStorage } from '@mantine/hooks'; import { useColorScheme, useLocalStorage } from '@mantine/hooks';
import { StylesPlaceholder } from '@mantine/remix'; import { StylesPlaceholder } from '@mantine/remix';
@ -54,7 +55,11 @@ import {
Briefcase, Briefcase,
BarChart2, BarChart2,
FileText, FileText,
Upload Upload,
Settings,
Lock,
User,
Users
} from 'react-feather'; } from 'react-feather';
import { NotificationsProvider } from '@mantine/notifications'; import { NotificationsProvider } from '@mantine/notifications';
@ -267,7 +272,7 @@ function Layout({ children }: React.PropsWithChildren<{}>) {
}` }`
})} })}
> >
<Group position="apart" align="center"> <Group position="left" align="center">
<ActionIcon <ActionIcon
variant="default" variant="default"
onClick={() => toggleColorScheme()} onClick={() => toggleColorScheme()}
@ -280,12 +285,13 @@ function Layout({ children }: React.PropsWithChildren<{}>) {
<Moon size={16} /> <Moon size={16} />
)} )}
</ActionIcon> </ActionIcon>
<Text size="sm">Toggle theme</Text>
</Group> </Group>
</Box> </Box>
</Navbar.Section> </Navbar.Section>
</MediaQuery> </MediaQuery>
{user && ( {user && (
<Navbar.Section grow mt="md"> <Navbar.Section mt="md">
<NavLink <NavLink
component={Link} component={Link}
to="/" to="/"
@ -349,21 +355,50 @@ function Layout({ children }: React.PropsWithChildren<{}>) {
)} )}
{user && ( {user && (
<Navbar.Section> <Navbar.Section>
<Form action="/logout" method="post"> <Menu shadow="md" width={200}>
<UnstyledButton type="submit" ml="sm" title="Logout"> <Menu.Target>
<Group> <UnstyledButton w="100%" title="Account / Logout" p="xs">
<ThemeIcon variant="light"> <Group w="100%">
<LogOut size={16} /> <ThemeIcon
variant="light"
sx={{
flexShrink: 1
}}
>
<User size={16} />
</ThemeIcon> </ThemeIcon>
<div> <div>
<Text>{user.email.split('@')[0]}</Text> <Text
sx={{
flex: 1
}}
>
{user.email.split('@')[0]}
</Text>
<Text size="xs" color="dimmed"> <Text size="xs" color="dimmed">
Click to logout {user.email.split('@')[1]}
</Text> </Text>
</div> </div>
</Group> </Group>
</UnstyledButton> </UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Account</Menu.Label>
<Menu.Item
icon={<Settings size={14} />}
component={Link}
to="/account"
>
Settings
</Menu.Item>
<Form action="/logout" method="post" noValidate>
<Menu.Item icon={<LogOut size={14} />} type="submit">
Logout
</Menu.Item>
</Form> </Form>
</Menu.Dropdown>
</Menu>
</Navbar.Section> </Navbar.Section>
)} )}
</Navbar> </Navbar>

134
app/routes/account.tsx Normal file
View file

@ -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<typeof action>();
const loaderData = useLoaderData<typeof loader>();
const emailRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
if (actionData?.errors?.email) {
emailRef.current?.focus();
}
}, [actionData]);
return (
<Box sx={{ maxWidth: 300 }} mx="auto">
<Title order={2} my="lg">
Account
</Title>
<Form method="post" noValidate>
<TextInput
mb={12}
label="Email address"
placeholder="your@email.com"
icon={<AtSign size={16} />}
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 }}
/>
<Group position="center" mt="sm">
<Button type="submit" leftIcon={<Save size={14} />}>
Update email
</Button>
</Group>
</Form>
<Outlet />
<Group position="center" mt="xl">
<Button
component={Link}
to="/account/updatepassword"
variant="light"
leftIcon={<Lock size={14} />}
>
Change password
</Button>
</Group>
<Group position="center" mt="md">
<Button
component={Link}
to="/account/delete"
variant="light"
color="red"
leftIcon={<Trash size={14} />}
>
Delete account
</Button>
</Group>
</Box>
);
}

View file

@ -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<typeof action>();
const navigate = useNavigate();
const passwordRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
if (actionData?.errors?.password) {
passwordRef.current?.focus();
}
}, [actionData]);
return (
<Modal
opened={true}
onClose={() => navigate('/account')}
title="Delete account"
>
<Alert
color="orange"
icon={<AlertTriangle size={16} />}
radius="md"
mb="md"
title="Are you sure?"
>
This action cannot be undone. All of your data will be permanently
deleted.
</Alert>
<Form method="delete" noValidate>
<PasswordInput
mb={12}
label="Password confirmation"
id="current-password"
ref={passwordRef}
placeholder="Type your password to confirm"
icon={<Lock size={16} />}
name="password"
type="password"
autoComplete="off"
aria-invalid={actionData?.errors?.password ? true : undefined}
error={actionData?.errors?.password ? true : undefined}
/>
{actionData?.errors?.password && (
<Text color="red" size="sm" mb={12}>
{actionData?.errors?.password}
</Text>
)}
<Group position="center" mt="xl">
<Button type="submit" color="red" leftIcon={<Trash size={14} />}>
Delete account
</Button>
</Group>
</Form>
</Modal>
);
}

View file

@ -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 (
<Modal
opened={true}
onClose={() => navigate('/')}
withCloseButton
shadow="md"
radius="md"
>
<Title order={3}>Your account has been deleted</Title>
<Text component="p" size="lg">
Sorry to see you go. If you change your mind, you can always create a
new account.
</Text>
<Text component="p" size="lg">
<a href="/">Go back to the homepage</a>
</Text>
</Modal>
);
}

View file

@ -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 (
<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 AccountUpdatePassword() {
const actionData = useActionData<typeof action>();
const navigate = useNavigate();
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?.password) {
passwordRef.current?.focus();
}
}, [actionData]);
return (
<Modal
opened={true}
onClose={() => navigate('/account')}
title="Change password"
>
<Form method="post" noValidate>
<PasswordInput
mb={12}
label="Current password"
id="current-password"
ref={passwordRef}
placeholder="********"
icon={<Lock size={16} />}
name="currentPassword"
type="password"
autoComplete="off"
aria-invalid={actionData?.errors?.password ? true : undefined}
error={actionData?.errors?.password ? true : undefined}
errorProps={{ children: actionData?.errors?.password }}
/>
<Popover
opened={popoverOpened}
position="bottom"
width="target"
transition="pop"
>
<Popover.Target>
<div
onFocusCapture={() => setPopoverOpened(true)}
onBlurCapture={() => setPopoverOpened(false)}
>
<PasswordInput
mb={12}
label="New password"
id="password"
ref={passwordRef}
placeholder="********"
icon={<Lock size={16} />}
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 }}
/>
</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}
label="Confirm new password"
id="confirm-password"
ref={passwordRef}
placeholder="********"
icon={<Lock size={16} />}
name="confirmPassword"
type="password"
autoComplete="off"
aria-invalid={actionData?.errors?.confirmPassword ? true : undefined}
error={actionData?.errors?.confirmPassword ? true : undefined}
errorProps={{ children: actionData?.errors?.confirmPassword }}
/>
<Group position="center" mt="xl">
<Button type="submit" leftIcon={<Save size={14} />}>
Update password
</Button>
</Group>
</Form>
</Modal>
);
}

View file

@ -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 (
<Alert
icon={<Check size={21} />}
color="teal"
mt="xl"
radius="md"
withCloseButton
closeButtonLabel="Close alert"
onClose={() => navigate('/account')}
>
Account updated successfully
</Alert>
);
}