Add possibility to define users as admin

This commit is contained in:
Meier Lukas
2023-08-10 20:50:31 +02:00
parent 73669aa61b
commit 5bb7418de5
7 changed files with 187 additions and 8 deletions

View File

@@ -2,7 +2,7 @@
// learn more about it in the docs: https://pris.ly/d/prisma-schema // learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
binaryTargets = ["native", "linux-musl-openssl-3.0.x"] binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
} }
@@ -51,6 +51,7 @@ model User {
password String? password String?
salt String? salt String?
isAdmin Boolean @default(false) isAdmin Boolean @default(false)
isOwner Boolean @default(false)
accounts Account[] accounts Account[]
sessions Session[] sessions Session[]
settings UserSettings? settings UserSettings?

View File

@@ -14,6 +14,17 @@
"delete": { "delete": {
"title": "Delete user {{name}}", "title": "Delete user {{name}}",
"text": "Are you sure, that you want to delete the user {{name}}? This will delete data associated with this account, but not any created dashboards by this user." "text": "Are you sure, that you want to delete the user {{name}}? This will delete data associated with this account, but not any created dashboards by this user."
},
"change-role": {
"promote": {
"title": "Promote user {{name}} to admin",
"text": "Are you sure, that you want to promote the user {{name}} to admin? This will give the user access to all resources on your Homarr instance."
},
"demote": {
"title": "Demote user {{name}} to user",
"text": "Are you sure, that you want to demote the user {{name}} to user? This will remove the user's access to all resources on your Homarr instance."
},
"confirm": "Confirm"
} }
}, },
"searchDoesntMatch": "Your search does not match any entries. Please adjust your filter." "searchDoesntMatch": "Your search does not match any entries. Please adjust your filter."

View File

@@ -0,0 +1,59 @@
import { Button, Group, Stack, Text, Title } from '@mantine/core';
import { ContextModalProps, modals } from '@mantine/modals';
import { Trans, useTranslation } from 'next-i18next';
import { api } from '~/utils/api';
type InnerProps = { id: string; name: string; type: 'promote' | 'demote' };
export const ChangeUserRoleModal = ({ id, innerProps }: ContextModalProps<InnerProps>) => {
const { t } = useTranslation('manage/users');
const utils = api.useContext();
const { isLoading, mutateAsync } = api.user.changeRole.useMutation({
onSuccess: async () => {
await utils.user.all.invalidate();
modals.close(id);
},
});
return (
<Stack>
<Text>{t(`modals.change-role.${innerProps.type}.text`, innerProps)} </Text>
<Group grow>
<Button
onClick={() => {
modals.close(id);
}}
variant="light"
color="gray"
>
{t('common:cancel')}
</Button>
<Button
onClick={async () => {
await mutateAsync(innerProps);
}}
disabled={isLoading}
variant="light"
color="red"
>
{t('modals.change-role.confirm')}
</Button>
</Group>
</Stack>
);
};
export const openRoleChangeModal = (user: InnerProps) => {
modals.openContextModal({
modal: 'changeUserRoleModal',
title: (
<Title order={4}>
<Trans
i18nKey={`manage/users:modals.change-role.${user.type}.title`}
values={{ name: user.name }}
/>
</Title>
),
innerProps: user,
});
};

View File

@@ -11,6 +11,7 @@ import { DeleteBoardModal } from './components/Manage/Board/delete-board.modal';
import { CopyInviteModal } from './components/Manage/User/Invite/copy-invite.modal'; import { CopyInviteModal } from './components/Manage/User/Invite/copy-invite.modal';
import { CreateInviteModal } from './components/Manage/User/Invite/create-invite.modal'; import { CreateInviteModal } from './components/Manage/User/Invite/create-invite.modal';
import { DeleteInviteModal } from './components/Manage/User/Invite/delete-invite.modal'; import { DeleteInviteModal } from './components/Manage/User/Invite/delete-invite.modal';
import { ChangeUserRoleModal } from './components/Manage/User/change-user-role.modal';
import { DeleteUserModal } from './components/Manage/User/delete-user.modal'; import { DeleteUserModal } from './components/Manage/User/delete-user.modal';
export const modals = { export const modals = {
@@ -27,6 +28,7 @@ export const modals = {
createBoardModal: CreateBoardModal, createBoardModal: CreateBoardModal,
copyInviteModal: CopyInviteModal, copyInviteModal: CopyInviteModal,
deleteBoardModal: DeleteBoardModal, deleteBoardModal: DeleteBoardModal,
changeUserRoleModal: ChangeUserRoleModal,
}; };
declare module '@mantine/modals' { declare module '@mantine/modals' {

View File

@@ -2,6 +2,7 @@ import {
ActionIcon, ActionIcon,
Autocomplete, Autocomplete,
Avatar, Avatar,
Badge,
Box, Box,
Button, Button,
Flex, Flex,
@@ -12,12 +13,14 @@ import {
Title, Title,
} from '@mantine/core'; } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks'; import { useDebouncedValue } from '@mantine/hooks';
import { IconPlus, IconTrash } from '@tabler/icons-react'; import { IconPlus, IconTrash, IconUserDown, IconUserUp } from '@tabler/icons-react';
import { GetServerSideProps } from 'next'; import { GetServerSideProps } from 'next';
import { useSession } from 'next-auth/react';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import Head from 'next/head'; import Head from 'next/head';
import Link from 'next/link'; import Link from 'next/link';
import { useState } from 'react'; import { useState } from 'react';
import { openRoleChangeModal } from '~/components/Manage/User/change-user-role.modal';
import { openDeleteUserModal } from '~/components/Manage/User/delete-user.modal'; import { openDeleteUserModal } from '~/components/Manage/User/delete-user.modal';
import { ManageLayout } from '~/components/layout/Templates/ManageLayout'; import { ManageLayout } from '~/components/layout/Templates/ManageLayout';
import { getServerAuthSession } from '~/server/auth'; import { getServerAuthSession } from '~/server/auth';
@@ -33,6 +36,7 @@ const ManageUsersPage = () => {
page: activePage, page: activePage,
search: debouncedSearch, search: debouncedSearch,
}); });
const { data: sessionData } = useSession();
const { t } = useTranslation('manage/users'); const { t } = useTranslation('manage/users');
@@ -84,9 +88,44 @@ const ManageUsersPage = () => {
<Group spacing="xs"> <Group spacing="xs">
<Avatar size="sm" /> <Avatar size="sm" />
<Text>{user.name}</Text> <Text>{user.name}</Text>
{user.isOwner ? (
<Badge color="pink" size="sm">
Owner
</Badge>
) : user.isAdmin ? (
<Badge color="red" size="sm">
Admin
</Badge>
) : null}
</Group> </Group>
<Group> <Group>
{user.isAdmin ? (
<ActionIcon
disabled={user.id === sessionData?.user?.id || user.isOwner}
onClick={() => {
openRoleChangeModal({
...user,
type: 'demote',
});
}}
>
<IconUserDown size="1rem" />
</ActionIcon>
) : (
<ActionIcon
onClick={() => {
openRoleChangeModal({
...user,
type: 'promote',
});
}}
>
<IconUserUp size="1rem" />
</ActionIcon>
)}
<ActionIcon <ActionIcon
disabled={user.id === sessionData?.user?.id || user.isOwner}
onClick={() => { onClick={() => {
openDeleteUserModal(user); openDeleteUserModal(user);
}} }}

View File

@@ -110,7 +110,7 @@ const FirstStepContent: StepContentComponent = ({ isMobile, next }) => {
const SecondStepContent: StepContentComponent = ({ isMobile, next }) => { const SecondStepContent: StepContentComponent = ({ isMobile, next }) => {
const [isSigninIn, setIsSigninIn] = useState(false); const [isSigninIn, setIsSigninIn] = useState(false);
const { mutateAsync } = api.user.createAdminAccount.useMutation(); const { mutateAsync } = api.user.createOwnerAccount.useMutation();
const { i18nZodResolver } = useI18nZodResolver(); const { i18nZodResolver } = useI18nZodResolver();
const form = useForm<z.infer<typeof signUpFormSchema>>({ const form = useForm<z.infer<typeof signUpFormSchema>>({

View File

@@ -20,7 +20,7 @@ import {
} from '../trpc'; } from '../trpc';
export const userRouter = createTRPCRouter({ export const userRouter = createTRPCRouter({
createAdminAccount: publicProcedure.input(signUpFormSchema).mutation(async ({ ctx, input }) => { createOwnerAccount: publicProcedure.input(signUpFormSchema).mutation(async ({ ctx, input }) => {
const userCount = await ctx.prisma.user.count(); const userCount = await ctx.prisma.user.count();
if (userCount > 0) { if (userCount > 0) {
throw new TRPCError({ throw new TRPCError({
@@ -33,7 +33,7 @@ export const userRouter = createTRPCRouter({
colorScheme: colorSchemeParser.parse(ctx.cookies[COOKIE_COLOR_SCHEME_KEY]), colorScheme: colorSchemeParser.parse(ctx.cookies[COOKIE_COLOR_SCHEME_KEY]),
language: ctx.cookies[COOKIE_LOCALE_KEY] ?? 'en', language: ctx.cookies[COOKIE_LOCALE_KEY] ?? 'en',
}, },
isAdmin: true, isOwner: true,
}); });
}), }),
count: publicProcedure.query(async ({ ctx }) => { count: publicProcedure.query(async ({ ctx }) => {
@@ -116,6 +116,45 @@ export const userRouter = createTRPCRouter({
}, },
}); });
}), }),
changeRole: adminProcedure
.input(z.object({ id: z.string(), type: z.enum(['promote', 'demote']) }))
.mutation(async ({ ctx, input }) => {
if (ctx.session?.user?.id === input.id) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You cannot change your own role',
});
}
const user = await ctx.prisma.user.findUnique({
where: {
id: input.id,
},
});
if (!user) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'User not found',
});
}
if (user.isOwner) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You cannot change the role of the owner',
});
}
await ctx.prisma.user.update({
where: {
id: input.id,
},
data: {
isAdmin: input.type === 'promote',
},
});
}),
changeLanguage: protectedProcedure changeLanguage: protectedProcedure
.input( .input(
z.object({ z.object({
@@ -237,7 +276,8 @@ export const userRouter = createTRPCRouter({
id: user.id, id: user.id,
name: user.name!, name: user.name!,
email: user.email, email: user.email,
emailVerified: user.emailVerified, isAdmin: user.isAdmin,
isOwner: user.isOwner,
})), })),
countPages: Math.ceil(countUsers / limit), countPages: Math.ceil(countUsers / limit),
}; };
@@ -253,6 +293,32 @@ export const userRouter = createTRPCRouter({
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const user = await ctx.prisma.user.findUnique({
where: {
id: input.id,
},
});
if (!user) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'User not found',
});
}
if (ctx.session?.user?.id === input.id) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You cannot change your own role',
});
}
if (user.isOwner) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You cannot change the role of the owner',
});
}
await ctx.prisma.user.delete({ await ctx.prisma.user.delete({
where: { where: {
id: input.id, id: input.id,
@@ -266,7 +332,7 @@ const createUserIfNotPresent = async (
input: z.infer<typeof createNewUserSchema>, input: z.infer<typeof createNewUserSchema>,
options: { options: {
defaultSettings?: Partial<UserSettings>; defaultSettings?: Partial<UserSettings>;
isAdmin?: boolean; isOwner?: boolean;
} | void } | void
) => { ) => {
const existingUser = await ctx.prisma.user.findFirst({ const existingUser = await ctx.prisma.user.findFirst({
@@ -290,7 +356,8 @@ const createUserIfNotPresent = async (
email: input.email, email: input.email,
password: hashedPassword, password: hashedPassword,
salt: salt, salt: salt,
isAdmin: options?.isAdmin ?? false, isAdmin: options?.isOwner ?? false,
isOwner: options?.isOwner ?? false,
settings: { settings: {
create: options?.defaultSettings ?? {}, create: options?.defaultSettings ?? {},
}, },