feat: add account management with update + delete
This commit is contained in:
parent
f51131eb00
commit
4d7e7ef167
|
|
@ -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 } });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
73
app/root.tsx
73
app/root.tsx
|
|
@ -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
|
||||||
</ThemeIcon>
|
variant="light"
|
||||||
<div>
|
sx={{
|
||||||
<Text>{user.email.split('@')[0]}</Text>
|
flexShrink: 1
|
||||||
<Text size="xs" color="dimmed">
|
}}
|
||||||
Click to logout
|
>
|
||||||
</Text>
|
<User size={16} />
|
||||||
</div>
|
</ThemeIcon>
|
||||||
</Group>
|
<div>
|
||||||
</UnstyledButton>
|
<Text
|
||||||
</Form>
|
sx={{
|
||||||
|
flex: 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.email.split('@')[0]}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" color="dimmed">
|
||||||
|
{user.email.split('@')[1]}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
</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>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
</Navbar.Section>
|
</Navbar.Section>
|
||||||
)}
|
)}
|
||||||
</Navbar>
|
</Navbar>
|
||||||
|
|
|
||||||
134
app/routes/account.tsx
Normal file
134
app/routes/account.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
app/routes/account/delete.tsx
Normal file
133
app/routes/account/delete.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
app/routes/account/deleted.tsx
Normal file
27
app/routes/account/deleted.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
277
app/routes/account/updatepassword.tsx
Normal file
277
app/routes/account/updatepassword.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
app/routes/account/updatesuccess.tsx
Normal file
26
app/routes/account/updatesuccess.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue