♻️ Rename registration token to invite, add created by

This commit is contained in:
Meier Lukas
2023-08-01 11:43:24 +02:00
parent df890b8c0a
commit f93d935175
18 changed files with 109 additions and 97 deletions

View File

@@ -53,6 +53,7 @@ model User {
accounts Account[]
sessions Session[]
settings UserSettings?
createdInvites Invite[]
}
model VerificationToken {
@@ -63,10 +64,12 @@ model VerificationToken {
@@unique([identifier, token])
}
model RegistrationToken {
model Invite {
id String @id @default(cuid())
token String @unique
expires DateTime
createdById String
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
}
model UserSettings {

View File

@@ -14,7 +14,7 @@
}
},
"buttons": {
"submit": "Register"
"submit": "Create account"
}
}
}

View File

@@ -23,7 +23,7 @@ export const Search = ({ isMobile }: SearchProps) => {
const [search, setSearch] = useState('');
const ref = useRef<HTMLInputElement>(null);
useHotkeys([['mod+K', () => ref.current?.focus()]]);
const { data: userWithSettings } = api.user.getWithSettings.useQuery();
const { data: userWithSettings } = api.user.withSettings.useQuery();
const { config } = useConfigContext();
const { colors } = useMantineTheme();
const router = useRouter();

View File

@@ -2,7 +2,7 @@ import { Button, Mark, Stack, Text } from '@mantine/core';
import { ContextModalProps, modals } from '@mantine/modals';
import Link from 'next/link';
export const CopyRegistrationToken = ({
export const CopyInviteModal = ({
context,
id,
innerProps,

View File

@@ -5,24 +5,20 @@ 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';
import { createInviteSchema } from '~/validations/invite';
export const CreateRegistrationTokenModal = ({
context,
id,
innerProps,
}: ContextModalProps<{}>) => {
export const CreateInviteModal = ({ id }: ContextModalProps<{}>) => {
const apiContext = api.useContext();
const { isLoading, mutateAsync } = api.registrationTokens.createRegistrationToken.useMutation({
const { isLoading, mutateAsync } = api.invites.create.useMutation({
onSuccess: async (data) => {
await apiContext.registrationTokens.getAllInvites.invalidate();
await apiContext.invites.all.invalidate();
modals.close(id);
modals.openContextModal({
modal: 'copyRegistrationTokenModal',
modal: 'copyInviteModal',
title: <Text weight="bold">Copy invitation</Text>,
innerProps: data,
})
});
},
});
@@ -35,14 +31,14 @@ export const CreateRegistrationTokenModal = ({
initialValues: {
expirationDate: dayjs().add(7, 'days').toDate(),
},
validate: i18nZodResolver(createRegistrationTokenSchema),
validate: i18nZodResolver(createInviteSchema),
});
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.
After the expiration, an invite will no longer be valid and the recipient of the invite
won't be able to create an account.
</Text>
<DateInput

View File

@@ -2,15 +2,15 @@ import { Button, Group, Stack, Text } from '@mantine/core';
import { ContextModalProps, modals } from '@mantine/modals';
import { api } from '~/utils/api';
export const DeleteRegistrationTokenModal = ({
export const DeleteInviteModal = ({
context,
id,
innerProps,
}: ContextModalProps<{ tokenId: string }>) => {
const apiContext = api.useContext();
const { isLoading, mutateAsync } = api.registrationTokens.deleteRegistrationToken.useMutation({
const { isLoading, mutateAsync } = api.invites.delete.useMutation({
onSuccess: async () => {
await apiContext.registrationTokens.getAllInvites.invalidate();
await apiContext.invites.all.invalidate();
modals.close(id);
},
});
@@ -18,7 +18,7 @@ export const DeleteRegistrationTokenModal = ({
<Stack>
<Text>
Are you sure, that you want to delete this invitation? Users with this link will no longer
be able to register using that link.
be able to create an account using that link.
</Text>
<Group grow>

View File

@@ -10,7 +10,7 @@ export const DeleteUserModal = ({
const apiContext = api.useContext();
const { isLoading, mutateAsync } = api.user.deleteUser.useMutation({
onSuccess: async () => {
await apiContext.user.getAll.invalidate();
await apiContext.user.all.invalidate();
modals.close(id);
},
});

View File

@@ -6,12 +6,12 @@ import { WidgetsEditModal } from '~/components/Dashboard/Tiles/Widgets/WidgetsEd
import { WidgetsRemoveModal } from '~/components/Dashboard/Tiles/Widgets/WidgetsRemoveModal';
import { CategoryEditModal } from '~/components/Dashboard/Wrappers/Category/CategoryEditModal';
import { DeleteUserModal } from './delete-user/delete-user.modal';
import { CreateRegistrationTokenModal } from './create-registration-token/create-registration-token.modal';
import { DeleteRegistrationTokenModal } from './delete-registration-token/delete-registration-token.modal';
import { CopyInviteModal } from './copy-invite/copy-invite.modal';
import { CreateDashboardModal } from './create-dashboard/create-dashboard.modal';
import { CopyRegistrationToken } from './copy-regristration-token/copy-registration-token.modal';
import { CreateInviteModal } from './create-invite/create-invite.modal';
import { DeleteBoardModal } from './delete-board/delete-board.modal';
import { DeleteInviteModal } from './delete-invite/delete-invite.modal';
import { DeleteUserModal } from './delete-user/delete-user.modal';
export const modals = {
editApp: EditAppModal,
@@ -22,11 +22,11 @@ export const modals = {
changeAppPositionModal: ChangeAppPositionModal,
changeIntegrationPositionModal: ChangeWidgetPositionModal,
deleteUserModal: DeleteUserModal,
createRegistrationTokenModal: CreateRegistrationTokenModal,
deleteRegistrationTokenModal: DeleteRegistrationTokenModal,
createInviteModal: CreateInviteModal,
deleteInviteModal: DeleteInviteModal,
createDashboardModal: CreateDashboardModal,
copyRegistrationTokenModal: CopyRegistrationToken,
deleteBoardModal: DeleteBoardModal
copyInviteModal: CopyInviteModal,
deleteBoardModal: DeleteBoardModal,
};
declare module '@mantine/modals' {

View File

@@ -8,17 +8,17 @@ import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import { useRouter } from 'next/router';
import { z } from 'zod';
import { prisma } from '~/server/db';
import { registerNamespaces } from '~/tools/server/translation-namespaces';
import { inviteNamespaces } from '~/tools/server/translation-namespaces';
import { api } from '~/utils/api';
import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
import { signUpFormSchema } from '~/validations/user';
export default function AuthInvitePage() {
const { t } = useTranslation('authentication/register');
const { t } = useTranslation('authentication/invite');
const { i18nZodResolver } = useI18nZodResolver();
const router = useRouter();
const query = router.query as { token: string };
const { mutateAsync } = api.user.register.useMutation();
const { mutateAsync } = api.user.createFromInvite.useMutation();
const form = useForm<z.infer<typeof signUpFormSchema>>({
validateInputOnChange: true,
@@ -37,7 +37,7 @@ export default function AuthInvitePage() {
void mutateAsync(
{
...values,
registerToken: query.token,
inviteToken: query.token,
},
{
onSuccess() {
@@ -124,7 +124,7 @@ export const getServerSideProps: GetServerSideProps = async ({ locale, query, pa
};
}
const token = await prisma.registrationToken.findUnique({
const token = await prisma.invite.findUnique({
where: {
id: routeParams.data.inviteId,
token: queryParams.data.token,
@@ -139,7 +139,7 @@ export const getServerSideProps: GetServerSideProps = async ({ locale, query, pa
return {
props: {
...(await serverSideTranslations(locale ?? '', registerNamespaces)),
...(await serverSideTranslations(locale ?? '', inviteNamespaces)),
},
};
};

View File

@@ -49,9 +49,9 @@ const CreateNewUserPage = () => {
});
const context = api.useContext();
const { mutateAsync, isLoading } = api.user.createUser.useMutation({
const { mutateAsync, isLoading } = api.user.create.useMutation({
onSettled: () => {
void context.user.getAll.invalidate();
void context.user.all.invalidate();
},
onSuccess: () => {
nextStep();

View File

@@ -24,7 +24,7 @@ const ManageUsersPage = () => {
const [activePage, setActivePage] = useState(0);
const [nonDebouncedSearch, setNonDebouncedSearch] = useState<string | undefined>('');
const [debouncedSearch] = useDebouncedValue<string | undefined>(nonDebouncedSearch, 200);
const { data } = api.user.getAll.useQuery({
const { data } = api.user.all.useQuery({
page: activePage,
search: debouncedSearch,
});

View File

@@ -19,7 +19,7 @@ import { api } from '~/utils/api';
const ManageUserInvitesPage = () => {
const [activePage, setActivePage] = useState(0);
const { data } = api.registrationTokens.getAllInvites.useQuery({
const { data } = api.invites.all.useQuery({
page: activePage,
});
@@ -40,17 +40,17 @@ const ManageUserInvitesPage = () => {
</Head>
<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.
Using invites, 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',
modal: 'createInviteModal',
title: 'Create invite',
innerProps: {},
});
}}
@@ -67,31 +67,35 @@ const ManageUserInvitesPage = () => {
<thead>
<tr>
<th>ID</th>
<th>Creator</th>
<th>Expires</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{data.registrationTokens.map((token, index) => (
{data.invites.map((invite, index) => (
<tr key={index}>
<td className={classes.tableIdCell}>
<Text lineClamp={1}>{token.id}</Text>
<td className={classes.tableGrowCell}>
<Text lineClamp={1}>{invite.id}</Text>
</td>
<td className={classes.tableGrowCell}>
<Text lineClamp={1}>{invite.creator}</Text>
</td>
<td className={classes.tableCell}>
{dayjs(dayjs()).isAfter(token.expires) ? (
<Text>expired {dayjs(token.expires).fromNow()}</Text>
{dayjs(dayjs()).isAfter(invite.expires) ? (
<Text>expired {dayjs(invite.expires).fromNow()}</Text>
) : (
<Text>in {dayjs(token.expires).fromNow(true)}</Text>
<Text>in {dayjs(invite.expires).fromNow(true)}</Text>
)}
</td>
<td className={classes.tableCell}>
<ActionIcon
onClick={() => {
modals.openContextModal({
modal: 'deleteRegistrationTokenModal',
title: <Text weight="bold">Delete registration token</Text>,
modal: 'deleteInviteModal',
title: <Text weight="bold">Delete invite</Text>,
innerProps: {
tokenId: token.id,
tokenId: invite.id,
},
});
}}
@@ -103,7 +107,7 @@ const ManageUserInvitesPage = () => {
</td>
</tr>
))}
{data.registrationTokens.length === 0 && (
{data.invites.length === 0 && (
<tr>
<td colSpan={3}>
<Center p="md">
@@ -137,8 +141,8 @@ const ManageUserInvitesPage = () => {
};
const useStyles = createStyles(() => ({
tableIdCell: {
width: '100%',
tableGrowCell: {
width: '50%',
},
tableCell: {
whiteSpace: 'nowrap',

View File

@@ -14,7 +14,7 @@ import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
import { updateSettingsValidationSchema } from '~/validations/user';
const PreferencesPage = ({ locale }: InferGetServerSidePropsType<typeof getServerSideProps>) => {
const { data } = api.user.getWithSettings.useQuery();
const { data } = api.user.withSettings.useQuery();
return (
<ManageLayout>
@@ -34,7 +34,7 @@ export const [FormProvider, useFormContext, useForm] =
const SettingsComponent = ({
settings,
}: {
settings: RouterOutputs['user']['getWithSettings']['settings'];
settings: RouterOutputs['user']['withSettings']['settings'];
}) => {
const languagesData = languages.map((language) => ({
image: 'https://img.icons8.com/clouds/256/000000/futurama-bender.png',

View File

@@ -8,6 +8,7 @@ import { dnsHoleRouter } from './routers/dns-hole';
import { dockerRouter } from './routers/docker/router';
import { downloadRouter } from './routers/download';
import { iconRouter } from './routers/icon';
import { inviteRouter } from './routers/invite';
import { mediaRequestsRouter } from './routers/media-request';
import { mediaServerRouter } from './routers/media-server';
import { overseerrRouter } from './routers/overseerr';
@@ -15,7 +16,6 @@ import { rssRouter } from './routers/rss';
import { usenetRouter } from './routers/usenet/router';
import { userRouter } from './routers/user';
import { weatherRouter } from './routers/weather';
import { inviteRouter } from './routers/registrationTokens';
/**
* This is the primary router for your server.
@@ -38,7 +38,7 @@ export const rootRouter = createTRPCRouter({
usenet: usenetRouter,
calendar: calendarRouter,
weather: weatherRouter,
registrationTokens: inviteRouter
invites: inviteRouter,
});
// export type definition of API

View File

@@ -2,34 +2,42 @@ import { randomBytes } from 'crypto';
import dayjs from 'dayjs';
import { z } from 'zod';
import { createTRPCRouter, publicProcedure } from '../trpc';
import { adminProcedure, createTRPCRouter, publicProcedure } from '../trpc';
export const inviteRouter = createTRPCRouter({
getAllInvites: publicProcedure
all: adminProcedure
.input(
z.object({
limit: z.number().min(1).max(100).nullish().default(10),
page: z.number().min(0)
page: z.number().min(0),
})
)
.query(async ({ ctx, input }) => {
const limit = input.limit ?? 50;
const registrationTokens = await ctx.prisma.registrationToken.findMany({
const invites = await ctx.prisma.invite.findMany({
take: limit,
skip: limit * input.page,
include: {
createdBy: {
select: {
name: true,
},
},
},
});
const countRegistrationTokens = await ctx.prisma.registrationToken.count();
const inviteCount = await ctx.prisma.invite.count();
return {
registrationTokens: registrationTokens.map((token) => ({
invites: invites.map((token) => ({
id: token.id,
expires: token.expires,
creator: token.createdBy.name,
})),
countPages: Math.ceil(countRegistrationTokens / limit)
countPages: Math.ceil(inviteCount / limit),
};
}),
createRegistrationToken: publicProcedure
create: adminProcedure
.input(
z.object({
expiration: z
@@ -39,9 +47,10 @@ export const inviteRouter = createTRPCRouter({
})
)
.mutation(async ({ ctx, input }) => {
const token = await ctx.prisma.registrationToken.create({
const token = await ctx.prisma.invite.create({
data: {
expires: input.expiration,
createdById: ctx.session.user.id,
token: randomBytes(20).toString('hex'),
},
});
@@ -52,10 +61,10 @@ export const inviteRouter = createTRPCRouter({
expires: token.expires,
};
}),
deleteRegistrationToken: publicProcedure
delete: adminProcedure
.input(z.object({ tokenId: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.prisma.registrationToken.delete({
await ctx.prisma.invite.delete({
where: {
id: input.tokenId,
},

View File

@@ -13,25 +13,25 @@ import { COOKIE_COLOR_SCHEME_KEY, COOKIE_LOCALE_KEY } from '../../../../data/con
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
export const userRouter = createTRPCRouter({
register: publicProcedure
createFromInvite: publicProcedure
.input(
signUpFormSchema.and(
z.object({
registerToken: z.string(),
inviteToken: z.string(),
})
)
)
.mutation(async ({ ctx, input }) => {
const token = await ctx.prisma.registrationToken.findUnique({
const token = await ctx.prisma.invite.findUnique({
where: {
token: input.registerToken,
token: input.inviteToken,
},
});
if (!token || token.expires < new Date()) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Invalid registration token',
message: 'Invalid invite token',
});
}
@@ -64,7 +64,7 @@ export const userRouter = createTRPCRouter({
},
},
});
await ctx.prisma.registrationToken.delete({
await ctx.prisma.invite.delete({
where: {
id: token.id,
},
@@ -115,7 +115,7 @@ export const userRouter = createTRPCRouter({
},
});
}),
getWithSettings: protectedProcedure.query(async ({ ctx, input }) => {
withSettings: protectedProcedure.query(async ({ ctx, input }) => {
const user = await ctx.prisma.user.findUnique({
where: {
id: ctx.session?.user?.id,
@@ -158,7 +158,7 @@ export const userRouter = createTRPCRouter({
});
}),
getAll: publicProcedure
all: publicProcedure
.input(
z.object({
limit: z.number().min(1).max(100).default(10),
@@ -199,7 +199,7 @@ export const userRouter = createTRPCRouter({
countPages: Math.ceil(countUsers / limit),
};
}),
createUser: publicProcedure.input(createNewUserSchema).mutation(async ({ ctx, input }) => {
create: publicProcedure.input(createNewUserSchema).mutation(async ({ ctx, input }) => {
const salt = bcrypt.genSaltSync(10);
const hashedPassword = hashPassword(input.password, salt);
await ctx.prisma.user.create({

View File

@@ -51,4 +51,4 @@ export const dashboardNamespaces = [
export const loginNamespaces = ['authentication/login', 'zod'];
export const registerNamespaces = ['authentication/register', 'zod'];
export const inviteNamespaces = ['authentication/invite', 'zod'];

View File

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