diff --git a/app/models/user.server.ts b/app/models/user.server.ts index 5a5b3bc..7ebefec 100644 --- a/app/models/user.server.ts +++ b/app/models/user.server.ts @@ -54,6 +54,10 @@ export async function deleteUserByEmail(email: User['email']) { return prisma.user.delete({ where: { email } }); } +export async function deleteUserById(id: User['id']) { + return prisma.user.delete({ where: { id } }); +} + export async function verifyLogin( email: User['email'], password: Password['hash'] @@ -82,3 +86,49 @@ export async function verifyLogin( 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 + }; +} diff --git a/app/root.tsx b/app/root.tsx index f7873c9..a53f1bf 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -35,7 +35,8 @@ import { UnstyledButton, ThemeIcon, NavLink, - Menu + Menu, + Badge } from '@mantine/core'; import { useColorScheme, useLocalStorage } from '@mantine/hooks'; import { StylesPlaceholder } from '@mantine/remix'; @@ -44,6 +45,7 @@ import { startNavigationProgress, completeNavigationProgress } from '@mantine/nprogress'; +import type { User as UserType } from './models/user.server'; import { getUser } from './session.server'; import { Sun, @@ -234,7 +236,9 @@ export function CatchBoundary() { } 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 location = useLocation(); const theme = useMantineTheme(); @@ -291,7 +295,7 @@ function Layout({ children }: React.PropsWithChildren<{}>) { {user && ( - + ) { /> )} + {!!user?.admin && ( + + + + + } + rightSection={ADMIN} + variant="light" + active={location.pathname.includes('/users')} + /> + + )} {user && ( diff --git a/app/routes/account.tsx b/app/routes/account.tsx index d766c5d..47d7a73 100644 --- a/app/routes/account.tsx +++ b/app/routes/account.tsx @@ -19,7 +19,8 @@ import { Title, Popover, Progress, - Modal + Modal, + Badge } from '@mantine/core'; import { AtSign, Check, Lock, Save, Trash, X } from 'react-feather'; import { requireUser } from '~/session.server'; @@ -80,6 +81,16 @@ export default function Account() { Account + + {loaderData.user.admin && ( + + Role:{' '} + + ADMIN + + + )} +
{ + 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(); + 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 ( +
+

+ Users +

+ + +
+ { + setSearchParams({ + page: page.toString(), + size: event.currentTarget.value + }); + }} + /> + {data.total / pageSize > 1 && ( + { + setSearchParams({ + page: page.toString(), + size: pageSize.toString() + }); + }} + /> + )} +
+
+ } + rightSection={ + setSearchParams((sp) => ({ ...sp, search: '' }))} + > + + + } + value={searchParams.get('search') || ''} + onChange={(event) => { + setSearchParams({ + search: event.currentTarget.value + }); + }} + /> +
+
+ + + {data.total} users + + {data.total !== data.filteredTotal && ( + <> + + | + + + {data.filteredTotal} matches + + + )} + + + + + + + + + + + + + + + {data.users.map((user) => ( + + + + + + + ))} + +
EmailCreated + Actions +
+ {user.id === data.user.id ? ( + + + {user.admin ? ( + + + + ) : ( + + )} + + + ) : ( + + {user.admin ? ( + + + + ) : ( + + )} + + )} + + {user.email} + + + {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))} + + + + + + + +
+
+ ); +} + +export function ErrorBoundary({ error }: { error: Error }) { + console.error(error); + + return ( + } title="Error" color="red"> + An unexpected error occurred: {error.message} + + ); +} + +export function CatchBoundary() { + const caught = useCatch(); + + if (caught.status === 404) { + return ( + } title="Error" color="red"> + Not found + + ); + } + + throw new Error(`Unexpected caught response with status: ${caught.status}`); +} diff --git a/app/routes/users/$userId.tsx b/app/routes/users/$userId.tsx new file mode 100644 index 0000000..2f7791d --- /dev/null +++ b/app/routes/users/$userId.tsx @@ -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(); + const navigate = useNavigate(); + + return ( + navigate('/users')} title="Delete user"> + } + radius="md" + mb="md" + title="Are you sure?" + > + This action cannot be undone. All of the user's data will be permanently + deleted. + + + + User: {loaderData.user?.email} + + + + + + + + + ); +} diff --git a/prisma/migrations/20230223112151_add_user_admin_flag/migration.sql b/prisma/migrations/20230223112151_add_user_admin_flag/migration.sql new file mode 100644 index 0000000..554507b --- /dev/null +++ b/prisma/migrations/20230223112151_add_user_admin_flag/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "admin" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6482292..611a245 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -8,8 +8,9 @@ generator client { } model User { - id String @id @default(uuid()) - email String @unique + id String @id @default(uuid()) + email String @unique + admin Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/prisma/seed.ts b/prisma/seed.ts index b271df0..47885f4 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -4,15 +4,33 @@ import bcrypt from 'bcryptjs'; const prisma = new PrismaClient(); async function seed() { - const email = 'admin@rawmaterial.it'; + const email = 'nicola@rawmaterial.it'; + const adminEmail = 'admin@rawmaterial.it'; // cleanup the existing database await prisma.user.delete({ where: { email } }).catch(() => { // 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 admin = await prisma.user.create({ + data: { + email: adminEmail, + admin: true, + password: { + create: { + hash: hashedPassword + } + } + } + }); + const user = await prisma.user.create({ data: { email,