mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-14 09:25:47 +01:00
@@ -1,13 +1,21 @@
|
||||
{
|
||||
"metaTitle": "Users",
|
||||
"pageTitle": "Manage users",
|
||||
"text": "Using users, you can configure who can edit your dashboards. Future versions of Homarr will have even more granular control over permissions and boards.",
|
||||
"buttons": {
|
||||
"create": "Create"
|
||||
},
|
||||
"filter": {
|
||||
"roles": {
|
||||
"all": "All",
|
||||
"normal": "Normal",
|
||||
"admin": "Admin",
|
||||
"owner": "Owner"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
"header": {
|
||||
"user": "User"
|
||||
"user": "User",
|
||||
"email": "E-Mail"
|
||||
}
|
||||
},
|
||||
"tooltips": {
|
||||
|
||||
55
public/locales/en/manage/users/edit.json
Normal file
55
public/locales/en/manage/users/edit.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"metaTitle": "User {{username}}",
|
||||
"back": "Back to user management",
|
||||
"sections": {
|
||||
"general": {
|
||||
"title": "General",
|
||||
"inputs": {
|
||||
"username": {
|
||||
"label": "Username"
|
||||
},
|
||||
"eMail": {
|
||||
"label": "E-Mail"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"title": "Security",
|
||||
"inputs": {
|
||||
"password": {
|
||||
"label": "New password"
|
||||
},
|
||||
"terminateExistingSessions": {
|
||||
"label": "Terminate existing sessions",
|
||||
"description": "Forces user to log in again on their devices"
|
||||
},
|
||||
"confirm": {
|
||||
"label": "Confirm",
|
||||
"description": "Password will be updated. Action cannot be reverted."
|
||||
}
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
"title": "Roles",
|
||||
"currentRole": "Current role: ",
|
||||
"badges": {
|
||||
"owner": "Owner",
|
||||
"admin": "Admin",
|
||||
"normal": "Normal"
|
||||
}
|
||||
},
|
||||
"deletion": {
|
||||
"title": "Account deletion",
|
||||
"inputs": {
|
||||
"confirmUsername": {
|
||||
"label": "Confirm username",
|
||||
"description": "Type username to confirm deletion"
|
||||
},
|
||||
"confirm": {
|
||||
"label": "Delete permanently",
|
||||
"description": "I am aware that this action is permanent and all account data will be lost."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
81
src/components/Manage/User/Edit/GeneralForm.tsx
Normal file
81
src/components/Manage/User/Edit/GeneralForm.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Box, Button, Group, TextInput, Title } from '@mantine/core';
|
||||
import { useForm, zodResolver } from '@mantine/form';
|
||||
import { IconAt, IconCheck, IconLetterCase } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { z } from 'zod';
|
||||
import { api } from '~/utils/api';
|
||||
|
||||
export const ManageUserGeneralForm = ({
|
||||
userId,
|
||||
defaultUsername,
|
||||
defaultEmail,
|
||||
}: {
|
||||
userId: string;
|
||||
defaultUsername: string;
|
||||
defaultEmail: string;
|
||||
}) => {
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
username: defaultUsername,
|
||||
eMail: defaultEmail,
|
||||
},
|
||||
validate: zodResolver(
|
||||
z.object({
|
||||
username: z.string(),
|
||||
eMail: z.string().email().or(z.literal('')),
|
||||
})
|
||||
),
|
||||
validateInputOnBlur: true,
|
||||
validateInputOnChange: true,
|
||||
});
|
||||
const { t } = useTranslation(['manage/users/edit', 'common']);
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { mutate, isLoading } = api.user.updateDetails.useMutation({
|
||||
onSettled: async () => {
|
||||
await utils.user.invalidate();
|
||||
form.resetDirty();
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit() {
|
||||
mutate({
|
||||
userId: userId,
|
||||
username: form.values.username,
|
||||
eMail: form.values.eMail,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Box maw={500}>
|
||||
<Title order={3}>{t('sections.general.title')}</Title>
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<TextInput
|
||||
icon={<IconLetterCase size="1rem" />}
|
||||
label={t('sections.general.inputs.username.label')}
|
||||
mb="md"
|
||||
withAsterisk
|
||||
{...form.getInputProps('username')}
|
||||
/>
|
||||
<TextInput
|
||||
icon={<IconAt size="1rem" />}
|
||||
label={t('sections.general.inputs.eMail.label')}
|
||||
{...form.getInputProps('eMail')}
|
||||
/>
|
||||
<Group position="right" mt="md">
|
||||
<Button
|
||||
disabled={!form.isDirty() || !form.isValid() || isLoading}
|
||||
loading={isLoading}
|
||||
leftIcon={<IconCheck size="1rem" />}
|
||||
color="green"
|
||||
variant="light"
|
||||
type="submit"
|
||||
>
|
||||
{t('common:save')}
|
||||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
83
src/components/Manage/User/Edit/ManageUserDanger.tsx
Normal file
83
src/components/Manage/User/Edit/ManageUserDanger.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Box, Button, Checkbox, Group, LoadingOverlay, PasswordInput, Title } from '@mantine/core';
|
||||
import { useForm, zodResolver } from '@mantine/form';
|
||||
import { IconTextSize, IconTrash } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { z } from 'zod';
|
||||
import { api } from '~/utils/api';
|
||||
|
||||
export const ManageUserDanger = ({
|
||||
userId,
|
||||
username,
|
||||
}: {
|
||||
userId: string;
|
||||
username: string | null;
|
||||
}) => {
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
username: '',
|
||||
confirm: false,
|
||||
},
|
||||
validate: zodResolver(
|
||||
z.object({
|
||||
username: z.literal(username),
|
||||
confirm: z.literal(true),
|
||||
})
|
||||
),
|
||||
validateInputOnBlur: true,
|
||||
validateInputOnChange: true,
|
||||
});
|
||||
|
||||
const apiUtils = api.useUtils();
|
||||
|
||||
const { mutate, isLoading } = api.user.deleteUser.useMutation({
|
||||
onSuccess: () => {
|
||||
window.location.href = '/manage/users';
|
||||
},
|
||||
onSettled: () => {
|
||||
void apiUtils.user.details.invalidate();
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useTranslation(['manage/users/edit', 'common']);
|
||||
|
||||
const handleSubmit = () => {
|
||||
mutate({
|
||||
id: userId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box maw={500}>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
<Title order={3}>{t('sections.deletion.title')}</Title>
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<PasswordInput
|
||||
icon={<IconTextSize size="1rem" />}
|
||||
label={t('sections.deletion.inputs.confirmUsername.label')}
|
||||
description={t('sections.deletion.inputs.confirmUsername.description')}
|
||||
mb="md"
|
||||
withAsterisk
|
||||
{...form.getInputProps('username')}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t('sections.deletion.inputs.confirm.label')}
|
||||
description={t('sections.deletion.inputs.confirm.description')}
|
||||
{...form.getInputProps('confirm')}
|
||||
/>
|
||||
<Group position="right" mt="md">
|
||||
<Button
|
||||
disabled={!form.isDirty() || !form.isValid()}
|
||||
leftIcon={<IconTrash size="1rem" />}
|
||||
loading={isLoading}
|
||||
color="red"
|
||||
variant="light"
|
||||
type="submit"
|
||||
>
|
||||
{t('common:delete')}
|
||||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
65
src/components/Manage/User/Edit/ManageUserRoles.tsx
Normal file
65
src/components/Manage/User/Edit/ManageUserRoles.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { ActionIcon, Badge, Box, Group, Title, Text, Tooltip, Button } from '@mantine/core';
|
||||
import { openRoleChangeModal } from '~/components/Manage/User/change-user-role.modal';
|
||||
import { IconUserDown, IconUserUp } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
export const ManageUserRoles = ({ user }: {
|
||||
user: {
|
||||
image: string | null;
|
||||
id: string;
|
||||
name: string | null;
|
||||
password: string | null;
|
||||
email: string | null;
|
||||
emailVerified: Date | null;
|
||||
salt: string | null;
|
||||
isAdmin: boolean;
|
||||
isOwner: boolean;
|
||||
}
|
||||
}) => {
|
||||
const { t } = useTranslation(['manage/users/edit', 'manage/users']);
|
||||
const { data: sessionData } = useSession();
|
||||
return (
|
||||
<Box maw={500}>
|
||||
<Title order={3}>
|
||||
{t('sections.roles.title')}
|
||||
</Title>
|
||||
|
||||
<Group mb={'md'}>
|
||||
<Text>{t('sections.roles.currentRole')}</Text>
|
||||
{user.isOwner ? (<Badge>{t('sections.roles.badges.owner')}</Badge>) : user.isAdmin ? (
|
||||
<Badge>{t('sections.roles.badges.admin')}</Badge>) : (<Badge>{t('sections.roles.badges.normal')}</Badge>)}
|
||||
</Group>
|
||||
|
||||
{user.isAdmin ? (
|
||||
<Button
|
||||
leftIcon={<IconUserDown size='1rem' />}
|
||||
disabled={user.id === sessionData?.user?.id || user.isOwner}
|
||||
onClick={() => {
|
||||
openRoleChangeModal({
|
||||
name: user.name as string,
|
||||
id: user.id,
|
||||
type: 'demote',
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('manage/users:tooltips.demoteAdmin')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
leftIcon={<IconUserUp size='1rem' />}
|
||||
onClick={() => {
|
||||
openRoleChangeModal({
|
||||
name: user.name as string,
|
||||
id: user.id,
|
||||
type: 'promote',
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
{t('manage/users:tooltips.promoteToAdmin')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
91
src/components/Manage/User/Edit/ManageUserSecurityForm.tsx
Normal file
91
src/components/Manage/User/Edit/ManageUserSecurityForm.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Box, Button, Checkbox, Group, LoadingOverlay, PasswordInput, Title } from '@mantine/core';
|
||||
import { useForm, zodResolver } from '@mantine/form';
|
||||
import { useInputState } from '@mantine/hooks';
|
||||
import { IconAlertTriangle, IconPassword } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { z } from 'zod';
|
||||
import { api } from '~/utils/api';
|
||||
|
||||
export const ManageUserSecurityForm = ({ userId }: { userId: string }) => {
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
password: '',
|
||||
terminateExistingSessions: false,
|
||||
confirm: false,
|
||||
},
|
||||
validate: zodResolver(
|
||||
z.object({
|
||||
password: z.string().min(3),
|
||||
terminateExistingSessions: z.boolean(),
|
||||
confirm: z.literal(true),
|
||||
})
|
||||
),
|
||||
validateInputOnBlur: true,
|
||||
validateInputOnChange: true,
|
||||
});
|
||||
|
||||
const [checked, setChecked] = useInputState(false);
|
||||
|
||||
const { t } = useTranslation(['manage/users/edit', 'common']);
|
||||
|
||||
const apiUtils = api.useUtils();
|
||||
|
||||
const { mutate, isLoading } = api.user.updatePassword.useMutation({
|
||||
onSettled: () => {
|
||||
void apiUtils.user.details.invalidate();
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (values: { password: string; terminateExistingSessions: boolean }) => {
|
||||
mutate({
|
||||
newPassword: values.password,
|
||||
terminateExistingSessions: values.terminateExistingSessions,
|
||||
userId: userId,
|
||||
});
|
||||
setChecked(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box maw={500}>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
<Title order={3}>{t('sections.security.title')}</Title>
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<PasswordInput
|
||||
icon={<IconPassword size="1rem" />}
|
||||
label={t('sections.security.inputs.password.label')}
|
||||
mb="md"
|
||||
withAsterisk
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t('sections.security.inputs.terminateExistingSessions.label')}
|
||||
description={t('sections.security.inputs.terminateExistingSessions.description')}
|
||||
mb="md"
|
||||
{...form.getInputProps('terminateExistingSessions')}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t('sections.security.inputs.confirm.label')}
|
||||
description={t('sections.security.inputs.confirm.description')}
|
||||
checked={checked}
|
||||
onClick={(event) => {
|
||||
setChecked(event.currentTarget.checked);
|
||||
}}
|
||||
{...form.getInputProps('confirm')}
|
||||
/>
|
||||
<Group position="right" mt="md">
|
||||
<Button
|
||||
disabled={!form.isDirty() || !form.isValid()}
|
||||
leftIcon={<IconAlertTriangle size="1rem" />}
|
||||
loading={isLoading}
|
||||
color="red"
|
||||
variant="light"
|
||||
type="submit"
|
||||
>
|
||||
{t('common:save')}
|
||||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -11,6 +11,7 @@ export const ChangeUserRoleModal = ({ id, innerProps }: ContextModalProps<InnerP
|
||||
const { isLoading, mutateAsync } = api.user.changeRole.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.user.all.invalidate();
|
||||
await utils.user.details.invalidate();
|
||||
modals.close(id);
|
||||
},
|
||||
});
|
||||
|
||||
90
src/pages/manage/users/[userId]/edit.tsx
Normal file
90
src/pages/manage/users/[userId]/edit.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Avatar, Divider, Group, Loader, Stack, Text, ThemeIcon, Title, UnstyledButton } from '@mantine/core';
|
||||
import { IconArrowLeft } from '@tabler/icons-react';
|
||||
import { GetServerSideProps } from 'next';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { ManageUserGeneralForm } from '~/components/Manage/User/Edit/GeneralForm';
|
||||
import { ManageUserDanger } from '~/components/Manage/User/Edit/ManageUserDanger';
|
||||
import { ManageUserSecurityForm } from '~/components/Manage/User/Edit/ManageUserSecurityForm';
|
||||
import { ManageLayout } from '~/components/layout/Templates/ManageLayout';
|
||||
import { getServerAuthSession } from '~/server/auth';
|
||||
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
|
||||
import { manageNamespaces } from '~/tools/server/translation-namespaces';
|
||||
import { api } from '~/utils/api';
|
||||
import { ManageUserRoles } from '~/components/Manage/User/Edit/ManageUserRoles';
|
||||
|
||||
const EditPage = () => {
|
||||
const { t } = useTranslation('manage/users/edit');
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { isLoading, data } = api.user.details.useQuery({ userId: router.query.userId as string });
|
||||
|
||||
const metaTitle = `${t('metaTitle', {
|
||||
username: data?.name,
|
||||
})} • Homarr`;
|
||||
|
||||
return (
|
||||
<ManageLayout>
|
||||
<Head>
|
||||
<title>{metaTitle}</title>
|
||||
</Head>
|
||||
<UnstyledButton component={Link} href='/manage/users'>
|
||||
<Group mb='md'>
|
||||
<ThemeIcon variant='default'>
|
||||
<IconArrowLeft size='1rem' />
|
||||
</ThemeIcon>
|
||||
<Text>{t('back')}</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
|
||||
<Group mb='xl'>
|
||||
<Avatar>{data?.name?.slice(0, 2).toUpperCase()}</Avatar>
|
||||
<Title>{data?.name}</Title>
|
||||
</Group>
|
||||
|
||||
{data ? (
|
||||
<Stack>
|
||||
<ManageUserGeneralForm
|
||||
defaultUsername={data?.name ?? ''}
|
||||
defaultEmail={data?.email ?? ''}
|
||||
userId={data.id}
|
||||
/>
|
||||
<Divider />
|
||||
<ManageUserSecurityForm userId={data.id} />
|
||||
<Divider />
|
||||
<ManageUserRoles user={data} />
|
||||
<Divider />
|
||||
<ManageUserDanger userId={data.id} username={data.name} />
|
||||
</Stack>
|
||||
) : (
|
||||
<Loader />
|
||||
)}
|
||||
</ManageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
||||
const session = await getServerAuthSession(ctx);
|
||||
const result = checkForSessionOrAskForLogin(ctx, session, () => session?.user.isAdmin == true);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const translations = await getServerSideTranslations(
|
||||
manageNamespaces,
|
||||
ctx.locale,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
return {
|
||||
props: {
|
||||
...translations,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default EditPage;
|
||||
@@ -1,28 +1,34 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Autocomplete,
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
NavLink,
|
||||
Pagination,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { useForm, zodResolver } from '@mantine/form';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { IconPlus, IconTrash, IconUserDown, IconUserUp } from '@tabler/icons-react';
|
||||
import {
|
||||
IconPencil,
|
||||
IconUser,
|
||||
IconUserPlus,
|
||||
IconUserShield,
|
||||
IconUserStar,
|
||||
IconX,
|
||||
} 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 { z } from 'zod';
|
||||
import { ManageLayout } from '~/components/layout/Templates/ManageLayout';
|
||||
import { getServerAuthSession } from '~/server/auth';
|
||||
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||
@@ -30,17 +36,49 @@ import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
|
||||
import { manageNamespaces } from '~/tools/server/translation-namespaces';
|
||||
import { api } from '~/utils/api';
|
||||
|
||||
export const PossibleRoleFilter = [
|
||||
{
|
||||
id: 'all',
|
||||
icon: IconUser,
|
||||
},
|
||||
{
|
||||
id: 'owner',
|
||||
icon: IconUserStar,
|
||||
},
|
||||
{
|
||||
id: 'admin',
|
||||
icon: IconUserShield,
|
||||
},
|
||||
{
|
||||
id: 'normal',
|
||||
icon: IconUser,
|
||||
},
|
||||
];
|
||||
|
||||
const ManageUsersPage = () => {
|
||||
const [activePage, setActivePage] = useState(0);
|
||||
const [nonDebouncedSearch, setNonDebouncedSearch] = useState<string | undefined>('');
|
||||
const [debouncedSearch] = useDebouncedValue<string | undefined>(nonDebouncedSearch, 200);
|
||||
const { data } = api.user.all.useQuery({
|
||||
page: activePage,
|
||||
search: debouncedSearch,
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
fullTextSearch: '',
|
||||
role: PossibleRoleFilter[0].id,
|
||||
},
|
||||
validate: zodResolver(
|
||||
z.object({
|
||||
fullTextSearch: z.string(),
|
||||
role: z
|
||||
.string()
|
||||
.transform((value) => (value.length > 0 ? value : undefined))
|
||||
.optional(),
|
||||
})
|
||||
),
|
||||
});
|
||||
const [debouncedForm] = useDebouncedValue(form, 200);
|
||||
const { data, isLoading } = api.user.all.useQuery({
|
||||
page: activePage,
|
||||
search: debouncedForm.values,
|
||||
});
|
||||
const { data: sessionData } = useSession();
|
||||
|
||||
const { t } = useTranslation('manage/users');
|
||||
const { t } = useTranslation(['manage/users', 'common']);
|
||||
|
||||
const metaTitle = `${t('metaTitle')} • Homarr`;
|
||||
|
||||
@@ -51,44 +89,88 @@ const ManageUsersPage = () => {
|
||||
</Head>
|
||||
|
||||
<Title mb="md">{t('pageTitle')}</Title>
|
||||
<Text mb="xl">{t('text')}</Text>
|
||||
|
||||
<Flex columnGap={10} justify="end" mb="md">
|
||||
<Autocomplete
|
||||
placeholder="Filter"
|
||||
data={
|
||||
(data?.users.map((user) => user.name).filter((name) => name !== null) as string[]) ?? []
|
||||
}
|
||||
variant="filled"
|
||||
onChange={(value) => {
|
||||
setNonDebouncedSearch(value);
|
||||
<Flex columnGap={10} mb="md">
|
||||
<TextInput
|
||||
rightSection={
|
||||
<IconX
|
||||
onClick={() => {
|
||||
form.setFieldValue('fullTextSearch', '');
|
||||
}}
|
||||
size="1rem"
|
||||
/>
|
||||
}
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
}}
|
||||
placeholder="Filter"
|
||||
variant="filled"
|
||||
{...form.getInputProps('fullTextSearch')}
|
||||
/>
|
||||
<Button
|
||||
component={Link}
|
||||
leftIcon={<IconPlus size="1rem" />}
|
||||
leftIcon={<IconUserPlus size="1rem" />}
|
||||
href="/manage/users/create"
|
||||
variant="default"
|
||||
color="green"
|
||||
variant="light"
|
||||
px="xl"
|
||||
>
|
||||
{t('buttons.create')}
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<Grid>
|
||||
<Grid.Col xs={12} md={4}>
|
||||
<Text color="dimmed" size="sm" mb="xs">
|
||||
Roles
|
||||
</Text>
|
||||
{PossibleRoleFilter.map((role) => (
|
||||
<NavLink
|
||||
key={role.id}
|
||||
icon={<role.icon size="1rem" />}
|
||||
rightSection={!isLoading && data && <Badge>{data?.stats.roles[role.id]}</Badge>}
|
||||
label={t(`filter.roles.${role.id}`)}
|
||||
active={form.values.role === role.id}
|
||||
onClick={() => {
|
||||
form.setFieldValue('role', role.id);
|
||||
}}
|
||||
sx={(theme) => ({
|
||||
borderRadius: theme.radius.md,
|
||||
marginBottom: 5,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</Grid.Col>
|
||||
<Grid.Col xs={12} md={8}>
|
||||
<Table mb="md" withBorder highlightOnHover>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('table.header.user')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.users.map((user, index) => (
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td colSpan={4}>
|
||||
<Group position="center" p="lg">
|
||||
<Loader variant="dots" />
|
||||
</Group>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{data?.users.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4}>
|
||||
<Text p="lg" color="dimmed">
|
||||
{t('searchDoesntMatch')}
|
||||
</Text>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{data?.users.map((user, index) => (
|
||||
<tr key={index}>
|
||||
<td>
|
||||
<Group position="apart">
|
||||
<Group spacing="xs">
|
||||
<td width="1%">
|
||||
<Avatar size="sm" />
|
||||
</td>
|
||||
<td>
|
||||
<Grid grow>
|
||||
<Grid.Col span={6} p={0}>
|
||||
<Group spacing="xs" noWrap>
|
||||
<Text>{user.name}</Text>
|
||||
{user.isOwner && (
|
||||
<Badge color="pink" size="sm">
|
||||
@@ -101,68 +183,33 @@ const ManageUsersPage = () => {
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Group>
|
||||
{user.isAdmin ? (
|
||||
<Tooltip label={t('tooltips.demoteAdmin')} withinPortal withArrow>
|
||||
<ActionIcon
|
||||
disabled={user.id === sessionData?.user?.id || user.isOwner}
|
||||
onClick={() => {
|
||||
openRoleChangeModal({
|
||||
...user,
|
||||
type: 'demote',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<IconUserDown size="1rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6} p={0}>
|
||||
{user.email ? (
|
||||
<Text>{user.email}</Text>
|
||||
) : (
|
||||
<Tooltip label={t('tooltips.promoteToAdmin')} withinPortal withArrow>
|
||||
<ActionIcon
|
||||
onClick={() => {
|
||||
openRoleChangeModal({
|
||||
...user,
|
||||
type: 'promote',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<IconUserUp size="1rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Text color="dimmed">No E-Mail</Text>
|
||||
)}
|
||||
|
||||
<Tooltip label={t('tooltips.deleteUser')} withinPortal withArrow>
|
||||
<ActionIcon
|
||||
disabled={user.id === sessionData?.user?.id || user.isOwner}
|
||||
onClick={() => {
|
||||
openDeleteUserModal(user);
|
||||
}}
|
||||
color="red"
|
||||
variant="light"
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</td>
|
||||
<td width="1%">
|
||||
<Button
|
||||
component={Link}
|
||||
href={`/manage/users/${user.id}/edit`}
|
||||
leftIcon={<IconPencil size="1rem" />}
|
||||
variant="default"
|
||||
>
|
||||
<IconTrash size="1rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
{t('common:edit')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{debouncedSearch && debouncedSearch.length > 0 && data.countPages === 0 && (
|
||||
<tr>
|
||||
<td colSpan={1}>
|
||||
<Box p={15}>
|
||||
<Text>{t('searchDoesntMatch')}</Text>
|
||||
</Box>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Grid.Col>
|
||||
<Group position="right" w="100%" px="sm">
|
||||
<Pagination
|
||||
total={data.countPages}
|
||||
value={activePage + 1}
|
||||
onNextPage={() => {
|
||||
setActivePage((prev) => prev + 1);
|
||||
}}
|
||||
@@ -172,9 +219,12 @@ const ManageUsersPage = () => {
|
||||
onChange={(targetPage) => {
|
||||
setActivePage(targetPage - 1);
|
||||
}}
|
||||
total={data?.countPages ?? 0}
|
||||
value={activePage + 1}
|
||||
withControls
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Grid>
|
||||
</ManageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
import { randomUUID } from 'crypto';
|
||||
import { eq, like, sql } from 'drizzle-orm';
|
||||
|
||||
import { and, eq, like, sql } from 'drizzle-orm';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { COOKIE_COLOR_SCHEME_KEY, COOKIE_LOCALE_KEY } from '../../../../data/constants';
|
||||
import { adminProcedure, createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
|
||||
|
||||
import { db } from '~/server/db';
|
||||
import { getTotalUserCountAsync } from '~/server/db/queries/user';
|
||||
import { UserSettings, invites, userSettings, users } from '~/server/db/schema';
|
||||
import { invites, sessions, users, userSettings, UserSettings } from '~/server/db/schema';
|
||||
import { hashPassword } from '~/utils/security';
|
||||
import {
|
||||
colorSchemeParser,
|
||||
@@ -13,9 +21,7 @@ import {
|
||||
signUpFormSchema,
|
||||
updateSettingsValidationSchema,
|
||||
} from '~/validations/user';
|
||||
|
||||
import { COOKIE_COLOR_SCHEME_KEY, COOKIE_LOCALE_KEY } from '../../../../data/constants';
|
||||
import { adminProcedure, createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
|
||||
import { PossibleRoleFilter } from '~/pages/manage/users';
|
||||
|
||||
export const userRouter = createTRPCRouter({
|
||||
createOwnerAccount: publicProcedure.input(signUpFormSchema).mutation(async ({ ctx, input }) => {
|
||||
@@ -34,6 +40,47 @@ export const userRouter = createTRPCRouter({
|
||||
isOwner: true,
|
||||
});
|
||||
}),
|
||||
updatePassword: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
userId: z.string(),
|
||||
newPassword: z.string().min(3),
|
||||
terminateExistingSessions: z.boolean(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.id, input.userId),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
});
|
||||
}
|
||||
|
||||
if (user.isOwner && user.id !== ctx.session.user.id) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Operation not allowed or incorrect user',
|
||||
});
|
||||
}
|
||||
|
||||
const salt = bcrypt.genSaltSync(10);
|
||||
const hashedPassword = hashPassword(input.newPassword, salt);
|
||||
|
||||
if (input.terminateExistingSessions) {
|
||||
await db.delete(sessions).where(eq(sessions.userId, input.userId));
|
||||
}
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
password: hashedPassword,
|
||||
salt: salt,
|
||||
})
|
||||
.where(eq(users.id, input.userId));
|
||||
}),
|
||||
count: publicProcedure.query(async () => {
|
||||
return await getTotalUserCountAsync();
|
||||
}),
|
||||
@@ -42,8 +89,8 @@ export const userRouter = createTRPCRouter({
|
||||
signUpFormSchema.and(
|
||||
z.object({
|
||||
inviteToken: z.string(),
|
||||
})
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const invite = await db.query.invites.findFirst({
|
||||
@@ -75,7 +122,7 @@ export const userRouter = createTRPCRouter({
|
||||
.input(
|
||||
z.object({
|
||||
colorScheme: colorSchemeParser,
|
||||
})
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await db
|
||||
@@ -122,7 +169,7 @@ export const userRouter = createTRPCRouter({
|
||||
.input(
|
||||
z.object({
|
||||
language: z.string(),
|
||||
})
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await db
|
||||
@@ -184,24 +231,48 @@ export const userRouter = createTRPCRouter({
|
||||
z.object({
|
||||
limit: z.number().min(1).max(100).default(10),
|
||||
page: z.number().min(0),
|
||||
search: z
|
||||
search: z.object({
|
||||
fullTextSearch: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => (value === '' ? undefined : value)),
|
||||
})
|
||||
role: z
|
||||
.string()
|
||||
.transform((value) => (value.length > 0 ? value : undefined))
|
||||
.optional(),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
|
||||
const roleFilter = () => {
|
||||
if (input.search.role === PossibleRoleFilter[1].id) {
|
||||
return eq(users.isOwner, true);
|
||||
}
|
||||
|
||||
if (input.search.role === PossibleRoleFilter[2].id) {
|
||||
return eq(users.isAdmin, true);
|
||||
}
|
||||
|
||||
if (input.search.role === PossibleRoleFilter[3].id) {
|
||||
return and(eq(users.isAdmin, false), eq(users.isOwner, false));
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const limit = input.limit;
|
||||
const dbUsers = await db.query.users.findMany({
|
||||
limit: limit + 1,
|
||||
offset: limit * input.page,
|
||||
where: input.search ? like(users.name, `%${input.search}%`) : undefined,
|
||||
where: and(input.search.fullTextSearch ? like(users.name, `%${input.search.fullTextSearch}%`) : undefined, roleFilter()),
|
||||
});
|
||||
|
||||
const countUsers = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(users)
|
||||
.where(input.search ? like(users.name, `%${input.search}%`) : undefined)
|
||||
.where(input.search.fullTextSearch ? like(users.name, `%${input.search.fullTextSearch}%`) : undefined)
|
||||
.where(roleFilter())
|
||||
.then((rows) => rows[0].count);
|
||||
|
||||
return {
|
||||
@@ -213,17 +284,54 @@ export const userRouter = createTRPCRouter({
|
||||
isOwner: user.isOwner,
|
||||
})),
|
||||
countPages: Math.ceil(countUsers / limit),
|
||||
stats: {
|
||||
roles: {
|
||||
all: (await db.select({ count: sql<number>`count(*)` }).from(users))[0]['count'],
|
||||
owner: (
|
||||
await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(users)
|
||||
.where(eq(users.isOwner, true))
|
||||
)[0]['count'],
|
||||
admin: (
|
||||
await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(users)
|
||||
.where(and(eq(users.isAdmin, true), eq(users.isOwner, false)))
|
||||
)[0]['count'],
|
||||
normal: (
|
||||
await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(users)
|
||||
.where(and(eq(users.isAdmin, false), eq(users.isOwner, false)))
|
||||
)[0]['count'],
|
||||
} as Record<string, number>,
|
||||
},
|
||||
};
|
||||
}),
|
||||
create: adminProcedure.input(createNewUserSchema).mutation(async ({ ctx, input }) => {
|
||||
create: adminProcedure.input(createNewUserSchema).mutation(async ({ input }) => {
|
||||
await createUserIfNotPresent(input);
|
||||
}),
|
||||
|
||||
details: adminProcedure.input(z.object({ userId: z.string() })).query(async ({ input }) => {
|
||||
return db.query.users.findFirst({
|
||||
where: eq(users.id, input.userId),
|
||||
});
|
||||
}),
|
||||
updateDetails: adminProcedure.input(z.object({
|
||||
userId: z.string(),
|
||||
username: z.string(),
|
||||
eMail: z.string().optional().transform(value => value?.length === 0 ? null : value),
|
||||
})).mutation(async ({ input }) => {
|
||||
await db.update(users).set({
|
||||
name: input.username,
|
||||
email: input.eMail as string | null,
|
||||
}).where(eq(users.id, input.userId));
|
||||
}),
|
||||
deleteUser: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
})
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const user = await db.query.users.findFirst({
|
||||
@@ -259,7 +367,7 @@ const createUserIfNotPresent = async (
|
||||
options: {
|
||||
defaultSettings?: Partial<UserSettings>;
|
||||
isOwner?: boolean;
|
||||
} | void
|
||||
} | void,
|
||||
) => {
|
||||
const existingUser = await db.query.users.findFirst({
|
||||
where: eq(users.name, input.username),
|
||||
|
||||
@@ -42,6 +42,7 @@ export const manageNamespaces = [
|
||||
'manage/users',
|
||||
'manage/users/invites',
|
||||
'manage/users/create',
|
||||
'manage/users/edit'
|
||||
];
|
||||
export const loginNamespaces = ['authentication/login'];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user