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
generator client {
provider = "prisma-client-js"
provider = "prisma-client-js"
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
}
@@ -51,6 +51,7 @@ model User {
password String?
salt String?
isAdmin Boolean @default(false)
isOwner Boolean @default(false)
accounts Account[]
sessions Session[]
settings UserSettings?

View File

@@ -14,6 +14,17 @@
"delete": {
"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."
},
"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."

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 { CreateInviteModal } from './components/Manage/User/Invite/create-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';
export const modals = {
@@ -27,6 +28,7 @@ export const modals = {
createBoardModal: CreateBoardModal,
copyInviteModal: CopyInviteModal,
deleteBoardModal: DeleteBoardModal,
changeUserRoleModal: ChangeUserRoleModal,
};
declare module '@mantine/modals' {

View File

@@ -2,6 +2,7 @@ import {
ActionIcon,
Autocomplete,
Avatar,
Badge,
Box,
Button,
Flex,
@@ -12,12 +13,14 @@ import {
Title,
} from '@mantine/core';
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 { useSession } from 'next-auth/react';
import { useTranslation } from 'next-i18next';
import Head from 'next/head';
import Link from 'next/link';
import { useState } from 'react';
import { openRoleChangeModal } from '~/components/Manage/User/change-user-role.modal';
import { openDeleteUserModal } from '~/components/Manage/User/delete-user.modal';
import { ManageLayout } from '~/components/layout/Templates/ManageLayout';
import { getServerAuthSession } from '~/server/auth';
@@ -33,6 +36,7 @@ const ManageUsersPage = () => {
page: activePage,
search: debouncedSearch,
});
const { data: sessionData } = useSession();
const { t } = useTranslation('manage/users');
@@ -84,9 +88,44 @@ const ManageUsersPage = () => {
<Group spacing="xs">
<Avatar size="sm" />
<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>
{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
disabled={user.id === sessionData?.user?.id || user.isOwner}
onClick={() => {
openDeleteUserModal(user);
}}

View File

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

View File

@@ -20,7 +20,7 @@ import {
} from '../trpc';
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();
if (userCount > 0) {
throw new TRPCError({
@@ -33,7 +33,7 @@ export const userRouter = createTRPCRouter({
colorScheme: colorSchemeParser.parse(ctx.cookies[COOKIE_COLOR_SCHEME_KEY]),
language: ctx.cookies[COOKIE_LOCALE_KEY] ?? 'en',
},
isAdmin: true,
isOwner: true,
});
}),
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
.input(
z.object({
@@ -237,7 +276,8 @@ export const userRouter = createTRPCRouter({
id: user.id,
name: user.name!,
email: user.email,
emailVerified: user.emailVerified,
isAdmin: user.isAdmin,
isOwner: user.isOwner,
})),
countPages: Math.ceil(countUsers / limit),
};
@@ -253,6 +293,32 @@ export const userRouter = createTRPCRouter({
})
)
.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({
where: {
id: input.id,
@@ -266,7 +332,7 @@ const createUserIfNotPresent = async (
input: z.infer<typeof createNewUserSchema>,
options: {
defaultSettings?: Partial<UserSettings>;
isAdmin?: boolean;
isOwner?: boolean;
} | void
) => {
const existingUser = await ctx.prisma.user.findFirst({
@@ -290,7 +356,8 @@ const createUserIfNotPresent = async (
email: input.email,
password: hashedPassword,
salt: salt,
isAdmin: options?.isAdmin ?? false,
isAdmin: options?.isOwner ?? false,
isOwner: options?.isOwner ?? false,
settings: {
create: options?.defaultSettings ?? {},
},