feat: users management with admin user

This commit is contained in:
Nicola Zambello 2023-02-23 14:17:29 +01:00
parent f5499de9d9
commit e4337a2c9d
Signed by: nzambello
GPG key ID: 56E4A92C2C1E50BA
8 changed files with 560 additions and 7 deletions

View file

@ -54,6 +54,10 @@ export async function deleteUserByEmail(email: User['email']) {
return prisma.user.delete({ where: { email } }); return prisma.user.delete({ where: { email } });
} }
export async function deleteUserById(id: User['id']) {
return prisma.user.delete({ where: { id } });
}
export async function verifyLogin( export async function verifyLogin(
email: User['email'], email: User['email'],
password: Password['hash'] password: Password['hash']
@ -82,3 +86,49 @@ export async function verifyLogin(
return userWithoutPassword; return userWithoutPassword;
} }
export async function getUsers({
search,
page,
size,
orderBy,
order
}: {
search?: string;
page?: number;
size?: number;
orderBy?: string;
order?: 'asc' | 'desc';
}) {
const totalUsers = await prisma.user.count();
const filteredTotal = await prisma.user.count({
where: {
email: {
contains: search || undefined
}
}
});
const paginatedUsers = await prisma.user.findMany({
where: {
email: {
contains: search || undefined
}
},
orderBy: {
[orderBy || 'createdAt']: order || 'desc'
},
skip: page && size ? (page - 1) * size : 0,
take: size
});
const nextPage = page && size && totalUsers > page * size ? page + 1 : null;
const previousPage = page && page > 2 ? page - 1 : null;
return {
total: totalUsers,
filteredTotal,
users: paginatedUsers,
nextPage,
previousPage
};
}

View file

@ -35,7 +35,8 @@ import {
UnstyledButton, UnstyledButton,
ThemeIcon, ThemeIcon,
NavLink, NavLink,
Menu Menu,
Badge
} 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';
@ -44,6 +45,7 @@ import {
startNavigationProgress, startNavigationProgress,
completeNavigationProgress completeNavigationProgress
} from '@mantine/nprogress'; } from '@mantine/nprogress';
import type { User as UserType } from './models/user.server';
import { getUser } from './session.server'; import { getUser } from './session.server';
import { import {
Sun, Sun,
@ -234,7 +236,9 @@ export function CatchBoundary() {
} }
function Layout({ children }: React.PropsWithChildren<{}>) { function Layout({ children }: React.PropsWithChildren<{}>) {
let user = useMatches().find((m) => m.id === 'root')?.data?.user; let user = useMatches().find((m) => m.id === 'root')?.data?.user as
| UserType
| undefined;
const [opened, setOpened] = useState(false); const [opened, setOpened] = useState(false);
const location = useLocation(); const location = useLocation();
const theme = useMantineTheme(); const theme = useMantineTheme();
@ -291,7 +295,7 @@ function Layout({ children }: React.PropsWithChildren<{}>) {
</Navbar.Section> </Navbar.Section>
</MediaQuery> </MediaQuery>
{user && ( {user && (
<Navbar.Section mt="md" grow> <Navbar.Section mt="md" grow={!user.admin}>
<NavLink <NavLink
component={Link} component={Link}
to="/" to="/"
@ -353,6 +357,34 @@ function Layout({ children }: React.PropsWithChildren<{}>) {
/> />
</Navbar.Section> </Navbar.Section>
)} )}
{!!user?.admin && (
<Navbar.Section
grow
sx={{
marginTop: 16,
paddingTop: 16,
borderTop: `1px solid ${
theme.colorScheme === 'dark'
? theme.colors.dark[4]
: theme.colors.gray[2]
}`
}}
>
<NavLink
component={Link}
to="/users"
label="Users"
icon={
<ThemeIcon variant="light">
<Users size={16} />
</ThemeIcon>
}
rightSection={<Badge variant="light">ADMIN</Badge>}
variant="light"
active={location.pathname.includes('/users')}
/>
</Navbar.Section>
)}
{user && ( {user && (
<Navbar.Section mt="lg"> <Navbar.Section mt="lg">
<Menu shadow="md" width={200}> <Menu shadow="md" width={200}>

View file

@ -19,7 +19,8 @@ import {
Title, Title,
Popover, Popover,
Progress, Progress,
Modal Modal,
Badge
} from '@mantine/core'; } from '@mantine/core';
import { AtSign, Check, Lock, Save, Trash, X } from 'react-feather'; import { AtSign, Check, Lock, Save, Trash, X } from 'react-feather';
import { requireUser } from '~/session.server'; import { requireUser } from '~/session.server';
@ -80,6 +81,16 @@ export default function Account() {
<Title order={2} my="lg"> <Title order={2} my="lg">
Account Account
</Title> </Title>
{loaderData.user.admin && (
<Text>
Role:{' '}
<Badge variant="light" mb="md">
ADMIN
</Badge>
</Text>
)}
<Form method="post" noValidate> <Form method="post" noValidate>
<TextInput <TextInput
mb={12} mb={12}

326
app/routes/users.tsx Normal file
View file

@ -0,0 +1,326 @@
import { useMemo } from 'react';
import {
Button,
Paper,
Text,
Menu,
ActionIcon,
Pagination,
NativeSelect,
Group,
useMantineTheme,
Alert,
ThemeIcon,
Table,
Indicator,
Tooltip,
TextInput
} from '@mantine/core';
import { json, LoaderArgs, MetaFunction, redirect } from '@remix-run/node';
import {
Form,
Link,
Outlet,
useCatch,
useLoaderData,
useSearchParams
} from '@remix-run/react';
import {
AlertTriangle,
Edit3,
Key,
Plus,
Search,
Settings,
Trash,
User as UserIcon,
X,
XCircle
} from 'react-feather';
import { requireUser } from '~/session.server';
import { getUsers, User } from '~/models/user.server';
export const meta: MetaFunction = () => {
return {
title: 'Users | WorkTimer',
description: 'Manage your users. You must be logged in as admin to do this.'
};
};
export async function loader({ request }: LoaderArgs) {
const user = await requireUser(request);
if (!user || !user.admin) return redirect('/login');
const url = new URL(request.url);
const page = url.searchParams.get('page')
? parseInt(url.searchParams.get('page')!, 10)
: 1;
const size = url.searchParams.get('size')
? parseInt(url.searchParams.get('size')!, 10)
: 25;
const orderBy = url.searchParams.get('orderBy') || 'createdAt';
const order = url.searchParams.get('order') || 'desc';
const search = url.searchParams.get('search') || undefined;
return json({
user,
...(await getUsers({
search,
page,
size,
orderBy,
order: order === 'asc' ? 'asc' : 'desc'
}))
});
}
export default function Users() {
const data = useLoaderData<typeof loader>();
const [searchParams, setSearchParams] = useSearchParams();
const theme = useMantineTheme();
const pageSize = useMemo(() => {
return parseInt(searchParams.get('size') || '25', 10);
}, [searchParams]);
const page = useMemo(() => {
return parseInt(searchParams.get('page') || '1', 10);
}, [searchParams]);
return (
<div>
<h1
style={{
position: 'absolute',
width: '1px',
height: '1px',
padding: 0,
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0,0,0,0)',
whiteSpace: 'nowrap',
border: 0
}}
>
Users
</h1>
<Paper
component="fieldset"
aria-controls="projects"
p="sm"
shadow="sm"
radius="md"
withBorder
style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap'
}}
>
<Button
component={Link}
to="/users/new"
variant="light"
radius={theme.radius.md}
leftIcon={<Plus />}
>
New User
</Button>
<div
style={{
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'space-between'
}}
>
<NativeSelect
name="size"
data={[
{ label: '25 / page', value: '25' },
{ label: '50 / page', value: '50' },
{ label: '100 / page', value: '100' }
]}
value={pageSize}
onChange={(event) => {
setSearchParams({
page: page.toString(),
size: event.currentTarget.value
});
}}
/>
{data.total / pageSize > 1 && (
<Pagination
style={{ marginLeft: 10 }}
page={page}
total={Math.ceil(data.total / pageSize)}
onChange={(page) => {
setSearchParams({
page: page.toString(),
size: pageSize.toString()
});
}}
/>
)}
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
width: '100%',
marginTop: 14,
flexGrow: 1,
flexShrink: 0
}}
>
<TextInput
type="search"
placeholder="Search users"
aria-label="Type to search users by email"
name="search"
style={{ width: 260 }}
icon={<Search size={16} />}
rightSection={
<ActionIcon
onClick={() => setSearchParams((sp) => ({ ...sp, search: '' }))}
>
<X size={16} strokeWidth={1} />
</ActionIcon>
}
value={searchParams.get('search') || ''}
onChange={(event) => {
setSearchParams({
search: event.currentTarget.value
});
}}
/>
</div>
</Paper>
<Group mt="lg" mb="md" mx="auto">
<Text size="sm" color="darkgray">
{data.total} users
</Text>
{data.total !== data.filteredTotal && (
<>
<Text size="sm" color="darkgray">
|
</Text>
<Text size="sm" color="darkgray">
{data.filteredTotal} matches
</Text>
</>
)}
</Group>
<Outlet />
<Table role="region" id="users">
<thead>
<tr>
<th scope="col"></th>
<th scope="col">Email</th>
<th scope="col">Created</th>
<th
scope="col"
style={{
textAlign: 'right'
}}
>
Actions
</th>
</tr>
</thead>
<tbody>
{data.users.map((user) => (
<tr key={user.id}>
<td>
{user.id === data.user.id ? (
<Indicator inline label="YOU" size={16}>
<ThemeIcon variant="light">
{user.admin ? (
<Tooltip label="Admin">
<Key />
</Tooltip>
) : (
<UserIcon />
)}
</ThemeIcon>
</Indicator>
) : (
<ThemeIcon variant="light">
{user.admin ? (
<Tooltip label="Admin">
<Key />
</Tooltip>
) : (
<UserIcon />
)}
</ThemeIcon>
)}
</td>
<td>
<Text weight={600}>{user.email}</Text>
</td>
<td>
<Text>
{Intl.DateTimeFormat('it-IT', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
}).format(new Date(user.createdAt))}
</Text>
</td>
<td>
<Group position="right">
<ActionIcon
disabled={user.id === data.user.id}
title="Delete user"
component={Link}
to={`/users/${user.id}`}
>
<Trash
size={14}
color={
user.id !== data.user.id
? theme.colors.red[8]
: undefined
}
/>
</ActionIcon>
</Group>
</td>
</tr>
))}
</tbody>
</Table>
</div>
);
}
export function ErrorBoundary({ error }: { error: Error }) {
console.error(error);
return (
<Alert icon={<AlertTriangle size={14} />} title="Error" color="red">
An unexpected error occurred: {error.message}
</Alert>
);
}
export function CatchBoundary() {
const caught = useCatch();
if (caught.status === 404) {
return (
<Alert icon={<AlertTriangle size={14} />} title="Error" color="red">
Not found
</Alert>
);
}
throw new Error(`Unexpected caught response with status: ${caught.status}`);
}

View file

@ -0,0 +1,113 @@
import type { ActionArgs, LoaderArgs, MetaFunction } from '@remix-run/node';
import { json, redirect } from '@remix-run/node';
import {
Form,
useActionData,
useLoaderData,
useNavigate,
useParams
} 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,
deleteUserById,
getUserById,
verifyLogin
} from '~/models/user.server';
import invariant from 'tiny-invariant';
export async function loader({ request, params }: LoaderArgs) {
invariant(params.userId, 'userId is required');
const loggedUser = await requireUser(request);
if (!loggedUser || !loggedUser.admin) return redirect('/login');
const user = await getUserById(params.userId);
return json({ user });
}
export async function action({ request, params }: ActionArgs) {
invariant(params.userId, 'userId is required');
const loggedUser = await requireUser(request);
if (!loggedUser || !loggedUser.admin)
return redirect('/login?redirectTo=/users');
if (request.method !== 'DELETE') {
return json(
{
errors: {
request: 'Invalid request'
}
},
{ status: 422 }
);
}
const user = await getUserById(params.userId);
if (!user) {
return json(
{
errors: {
request: 'User not found'
}
},
{ status: 404 }
);
}
await deleteUserById(user.id);
return redirect('/users');
}
export const meta: MetaFunction = () => {
return {
title: 'Users | WorkTimer',
description:
'Manage users and their permissions. Delete users and their data.'
};
};
export default function AccountDelete() {
const loaderData = useLoaderData<typeof loader>();
const navigate = useNavigate();
return (
<Modal opened={true} onClose={() => navigate('/users')} title="Delete user">
<Alert
color="orange"
icon={<AlertTriangle size={16} />}
radius="md"
mb="md"
title="Are you sure?"
>
This action cannot be undone. All of the user's data will be permanently
deleted.
</Alert>
<Text size="sm" mb="md">
User: {loaderData.user?.email}
</Text>
<Form method="delete" noValidate>
<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,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "admin" BOOLEAN NOT NULL DEFAULT false;

View file

@ -10,6 +10,7 @@ generator client {
model User { model User {
id String @id @default(uuid()) id String @id @default(uuid())
email String @unique email String @unique
admin Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

View file

@ -4,15 +4,33 @@ import bcrypt from 'bcryptjs';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
async function seed() { async function seed() {
const email = 'admin@rawmaterial.it'; const email = 'nicola@rawmaterial.it';
const adminEmail = 'admin@rawmaterial.it';
// cleanup the existing database // cleanup the existing database
await prisma.user.delete({ where: { email } }).catch(() => { await prisma.user.delete({ where: { email } }).catch(() => {
// no worries if it doesn't exist yet // no worries if it doesn't exist yet
}); });
// cleanup the existing database
await prisma.user.delete({ where: { email: adminEmail } }).catch(() => {
// no worries if it doesn't exist yet
});
const hashedPassword = await bcrypt.hash('rawmaterial', 10); const hashedPassword = await bcrypt.hash('rawmaterial', 10);
const admin = await prisma.user.create({
data: {
email: adminEmail,
admin: true,
password: {
create: {
hash: hashedPassword
}
}
}
});
const user = await prisma.user.create({ const user = await prisma.user.create({
data: { data: {
email, email,