Add procedure for registration tokens management

This commit is contained in:
Manuel
2023-07-29 21:11:52 +02:00
parent cf12c8575d
commit b4c188e797
7 changed files with 232 additions and 10 deletions

View File

@@ -0,0 +1,72 @@
import { Button, Group, Stack, Text } from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { useForm, zodResolver } from '@mantine/form';
import { ContextModalProps, modals } from '@mantine/modals';
import dayjs from 'dayjs';
import { api } from '~/utils/api';
import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
import { createRegistrationTokenSchema } from '~/validations/registration-token';
export const CreateRegistrationTokenModal = ({
context,
id,
innerProps,
}: ContextModalProps<{}>) => {
const apiContext = api.useContext();
const { isLoading, mutateAsync } = api.registrationTokens.createRegistrationToken.useMutation({
onSuccess: async () => {
await apiContext.registrationTokens.getAllInvites.invalidate();
modals.close(id);
},
});
const { i18nZodResolver } = useI18nZodResolver();
const minDate = dayjs().add(5, 'minutes').toDate();
const maxDate = dayjs().add(6, 'months').toDate();
const form = useForm({
initialValues: {
expirationDate: dayjs().add(7, 'days').toDate(),
},
validate: i18nZodResolver(createRegistrationTokenSchema),
});
return (
<Stack>
<Text>
After the expiration, a token will no longer be valid and the recipient of the token won't
be able to create an account.
</Text>
<DateInput
label="Expiration date"
withAsterisk
popoverProps={{ withinPortal: true }}
minDate={minDate}
maxDate={maxDate}
{...form.getInputProps('expirationDate')}
/>
<Group grow>
<Button
onClick={() => {
modals.close(id);
}}
>
Cancel
</Button>
<Button
onClick={async () => {
await mutateAsync({
expiration: form.values.expirationDate,
});
}}
disabled={isLoading}
>
Create
</Button>
</Group>
</Stack>
);
};

View File

@@ -7,6 +7,7 @@ import { WidgetsRemoveModal } from '~/components/Dashboard/Tiles/Widgets/Widgets
import { CategoryEditModal } from '~/components/Dashboard/Wrappers/Category/CategoryEditModal'; import { CategoryEditModal } from '~/components/Dashboard/Wrappers/Category/CategoryEditModal';
import { DeleteUserModal } from './delete-user/delete-user.modal'; import { DeleteUserModal } from './delete-user/delete-user.modal';
import { CreateRegistrationTokenModal } from './create-registration-token/create-registration-token.modal';
export const modals = { export const modals = {
editApp: EditAppModal, editApp: EditAppModal,
@@ -17,6 +18,7 @@ export const modals = {
changeAppPositionModal: ChangeAppPositionModal, changeAppPositionModal: ChangeAppPositionModal,
changeIntegrationPositionModal: ChangeWidgetPositionModal, changeIntegrationPositionModal: ChangeWidgetPositionModal,
deleteUserModal: DeleteUserModal, deleteUserModal: DeleteUserModal,
createRegistrationTokenModal: CreateRegistrationTokenModal
}; };
declare module '@mantine/modals' { declare module '@mantine/modals' {

View File

@@ -29,8 +29,6 @@ const ManageUsersPage = () => {
} }
); );
const { mutateAsync: deleteUserMutateAsync } = api.user.deleteUser.useMutation();
const [activePage, _] = useState(0); const [activePage, _] = useState(0);
return ( return (
@@ -39,7 +37,10 @@ const ManageUsersPage = () => {
<title>Users Homarr</title> <title>Users Homarr</title>
</Head> </Head>
<Title mb="xl">Manage users</Title> <Title mb="md">Manage users</Title>
<Text mb="xl">
Using users, you have granular control who can access, edit or delete resources on your Homarr instance.
</Text>
<Group position="apart" mb="md"> <Group position="apart" mb="md">
<SegmentedControl <SegmentedControl
@@ -74,8 +75,8 @@ const ManageUsersPage = () => {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{data.pages[activePage].users.map((user) => ( {data.pages[activePage].users.map((user, index) => (
<tr> <tr key={index}>
<td> <td>
<Group position="apart"> <Group position="apart">
<Group spacing="xs"> <Group spacing="xs">
@@ -90,8 +91,8 @@ const ManageUsersPage = () => {
title: <Text weight="bold">Delete user ${user.name}</Text>, title: <Text weight="bold">Delete user ${user.name}</Text>,
innerProps: { innerProps: {
userId: user.id, userId: user.id,
username: user.name ?? '' username: user.name ?? '',
} },
}); });
}} }}
color="red" color="red"

View File

@@ -1,14 +1,98 @@
import { Title } from '@mantine/core'; import { ActionIcon, Button, Center, Flex, Pagination, Table, Text, Title } from '@mantine/core';
import { Head } from 'next/document'; import { modals } from '@mantine/modals';
import { IconPlus, IconTrash } from '@tabler/icons-react';
import dayjs from 'dayjs';
import Head from 'next/head';
import { useState } from 'react';
import { MainLayout } from '~/components/layout/admin/main-admin.layout'; import { MainLayout } from '~/components/layout/admin/main-admin.layout';
import { modals as applicationModals } from '~/modals/modals';
import { api } from '~/utils/api';
const ManageUserInvitesPage = () => { const ManageUserInvitesPage = () => {
const { data, isFetched, fetchPreviousPage, fetchNextPage } =
api.registrationTokens.getAllInvites.useInfiniteQuery({
limit: 10,
});
const [activePage, _] = useState(0);
return ( return (
<MainLayout> <MainLayout>
<Head> <Head>
<title>User invites Homarr</title> <title>User invites Homarr</title>
</Head> </Head>
<Title>Manage user invites</Title> <Title mb="md">Manage user invites</Title>
<Text mb="xl">
Using registration tokens, you can invite users to your Homarr instance. An invitation will
only be valid for a certain time-span and can be used once. The expiration must be between 5
minutes and 12 months upon creation.
</Text>
<Flex justify="end" mb="md">
<Button
onClick={() => {
modals.openContextModal({
modal: 'createRegistrationTokenModal',
title: 'Create registration token',
innerProps: {},
});
}}
leftIcon={<IconPlus size="1rem" />}
variant="default"
>
Create invitation
</Button>
</Flex>
{data && (
<>
<Table mb="md" withBorder highlightOnHover>
<thead>
<tr>
<th>ID</th>
<th>Expires</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{data.pages[activePage].registrationTokens.map((token, index) => (
<tr key={index}>
<td>
<Text>{token.id}</Text>
</td>
<td>
{dayjs(dayjs()).isAfter(token.expires) ? (
<Text>expired {dayjs(token.expires).fromNow()}</Text>
) : (
<Text>in {dayjs(token.expires).fromNow(true)}</Text>
)}
</td>
<td>
<ActionIcon onClick={() => {}} color="red" variant="light">
<IconTrash size="1rem" />
</ActionIcon>
</td>
</tr>
))}
{data.pages[activePage].registrationTokens.length === 0 && (
<tr>
<td colSpan={3}>
<Center p="md">
<Text color="dimmed">There are no invitations yet.</Text>
</Center>
</td>
</tr>
)}
</tbody>
</Table>
<Pagination
total={data.pages.length}
value={activePage + 1}
onNextPage={fetchNextPage}
onPreviousPage={fetchPreviousPage}
/>
</>
)}
</MainLayout> </MainLayout>
); );
}; };

View File

@@ -15,6 +15,7 @@ import { rssRouter } from './routers/rss';
import { usenetRouter } from './routers/usenet/router'; import { usenetRouter } from './routers/usenet/router';
import { userRouter } from './routers/user'; import { userRouter } from './routers/user';
import { weatherRouter } from './routers/weather'; import { weatherRouter } from './routers/weather';
import { inviteRouter } from './routers/registrationTokens';
/** /**
* This is the primary router for your server. * This is the primary router for your server.
@@ -37,6 +38,7 @@ export const rootRouter = createTRPCRouter({
usenet: usenetRouter, usenet: usenetRouter,
calendar: calendarRouter, calendar: calendarRouter,
weather: weatherRouter, weather: weatherRouter,
registrationTokens: inviteRouter
}); });
// export type definition of API // export type definition of API

View File

@@ -0,0 +1,52 @@
import dayjs from 'dayjs';
import { z } from 'zod';
import { createTRPCRouter, publicProcedure } from '../trpc';
import { randomBytes } from 'crypto';
export const inviteRouter = createTRPCRouter({
getAllInvites: publicProcedure
.input(
z.object({
limit: z.number().min(1).max(100).nullish(),
cursor: z.string().nullish(),
})
)
.query(async ({ ctx, input }) => {
const limit = input.limit ?? 50;
const cursor = input.cursor;
const registrationTokens = await ctx.prisma.registrationToken.findMany({
take: limit + 1, // get an extra item at the end which we'll use as next cursor
cursor: cursor ? { id: cursor } : undefined,
});
let nextCursor: typeof cursor | undefined = undefined;
if (registrationTokens.length > limit) {
const nextItem = registrationTokens.pop();
nextCursor = nextItem!.id;
}
return {
registrationTokens: registrationTokens.map((token) => ({
id: token.id,
expires: token.expires,
})),
nextCursor,
};
}),
createRegistrationToken: publicProcedure.input(
z.object({
expiration: z
.date()
.min(dayjs().add(5, 'minutes').toDate())
.max(dayjs().add(6, 'months').toDate()),
})
).mutation(async ({ ctx, input }) => {
await ctx.prisma.registrationToken.create({
data: {
expires: input.expiration,
token: randomBytes(20).toString('hex'),
}
});
}),
});

View File

@@ -0,0 +1,9 @@
import dayjs from 'dayjs';
import { z } from 'zod';
export const createRegistrationTokenSchema = z.object({
expiration: z
.date()
.min(dayjs().add(5, 'minutes').toDate())
.max(dayjs().add(6, 'months').toDate()),
});