feat: users management with admin user
This commit is contained in:
parent
f5499de9d9
commit
e4337a2c9d
|
|
@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
38
app/root.tsx
38
app/root.tsx
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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
326
app/routes/users.tsx
Normal 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}`);
|
||||||
|
}
|
||||||
113
app/routes/users/$userId.tsx
Normal file
113
app/routes/users/$userId.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "admin" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
@ -8,8 +8,9 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue