mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-11 07:55:52 +01:00
✨ Add possibility to define users as admin
This commit is contained in:
@@ -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?
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
59
src/components/Manage/User/change-user-role.modal.tsx
Normal file
59
src/components/Manage/User/change-user-role.modal.tsx
Normal 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,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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' {
|
||||||
|
|||||||
@@ -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);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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>>({
|
||||||
|
|||||||
@@ -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 ?? {},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user