diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 17e1ab679..1e4f04d3a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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? diff --git a/public/locales/en/manage/users.json b/public/locales/en/manage/users.json index eace43f6c..51ab008e0 100644 --- a/public/locales/en/manage/users.json +++ b/public/locales/en/manage/users.json @@ -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." diff --git a/src/components/Manage/User/change-user-role.modal.tsx b/src/components/Manage/User/change-user-role.modal.tsx new file mode 100644 index 000000000..45c09a7aa --- /dev/null +++ b/src/components/Manage/User/change-user-role.modal.tsx @@ -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) => { + 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 ( + + {t(`modals.change-role.${innerProps.type}.text`, innerProps)} + + + + + + + ); +}; + +export const openRoleChangeModal = (user: InnerProps) => { + modals.openContextModal({ + modal: 'changeUserRoleModal', + title: ( + + <Trans + i18nKey={`manage/users:modals.change-role.${user.type}.title`} + values={{ name: user.name }} + /> + + ), + innerProps: user, + }); +}; diff --git a/src/modals.ts b/src/modals.ts index ba06fbc97..90865cb68 100644 --- a/src/modals.ts +++ b/src/modals.ts @@ -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' { diff --git a/src/pages/manage/users/index.tsx b/src/pages/manage/users/index.tsx index dc5c34f82..2b0fa77e7 100644 --- a/src/pages/manage/users/index.tsx +++ b/src/pages/manage/users/index.tsx @@ -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 = () => { {user.name} + {user.isOwner ? ( + + Owner + + ) : user.isAdmin ? ( + + Admin + + ) : null} + {user.isAdmin ? ( + { + openRoleChangeModal({ + ...user, + type: 'demote', + }); + }} + > + + + ) : ( + { + openRoleChangeModal({ + ...user, + type: 'promote', + }); + }} + > + + + )} + { openDeleteUserModal(user); }} diff --git a/src/pages/onboard.tsx b/src/pages/onboard.tsx index 18a579ad6..aa776bc2e 100644 --- a/src/pages/onboard.tsx +++ b/src/pages/onboard.tsx @@ -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>({ diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index 6d3687b44..6b8db0a3f 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -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, options: { defaultSettings?: Partial; - 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 ?? {}, },