mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-13 08:55:48 +01:00
✨ Add procedure for registration tokens management
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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' {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
52
src/server/api/routers/registrationTokens.ts
Normal file
52
src/server/api/routers/registrationTokens.ts
Normal 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'),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
9
src/validations/registration-token.ts
Normal file
9
src/validations/registration-token.ts
Normal 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()),
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user