#1616 better user management (#1748)

This commit is contained in:
Manuel
2023-12-20 10:18:24 +01:00
committed by GitHub
parent 199b711324
commit 553fa98e61
11 changed files with 760 additions and 127 deletions

View File

@@ -1,13 +1,21 @@
{ {
"metaTitle": "Users", "metaTitle": "Users",
"pageTitle": "Manage 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": { "buttons": {
"create": "Create" "create": "Create"
}, },
"filter": {
"roles": {
"all": "All",
"normal": "Normal",
"admin": "Admin",
"owner": "Owner"
}
},
"table": { "table": {
"header": { "header": {
"user": "User" "user": "User",
"email": "E-Mail"
} }
}, },
"tooltips": { "tooltips": {

View 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."
}
}
}
}
}

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View File

@@ -11,6 +11,7 @@ export const ChangeUserRoleModal = ({ id, innerProps }: ContextModalProps<InnerP
const { isLoading, mutateAsync } = api.user.changeRole.useMutation({ const { isLoading, mutateAsync } = api.user.changeRole.useMutation({
onSuccess: async () => { onSuccess: async () => {
await utils.user.all.invalidate(); await utils.user.all.invalidate();
await utils.user.details.invalidate();
modals.close(id); modals.close(id);
}, },
}); });

View 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;

View File

@@ -1,28 +1,34 @@
import { import {
ActionIcon,
Autocomplete,
Avatar, Avatar,
Badge, Badge,
Box,
Button, Button,
Flex, Flex,
Grid,
Group, Group,
Loader,
NavLink,
Pagination, Pagination,
Table, Table,
Text, Text,
TextInput,
Title, Title,
Tooltip,
} from '@mantine/core'; } from '@mantine/core';
import { useForm, zodResolver } from '@mantine/form';
import { useDebouncedValue } from '@mantine/hooks'; 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 { 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 { z } from 'zod';
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';
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
@@ -30,17 +36,49 @@ import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
import { manageNamespaces } from '~/tools/server/translation-namespaces'; import { manageNamespaces } from '~/tools/server/translation-namespaces';
import { api } from '~/utils/api'; 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 ManageUsersPage = () => {
const [activePage, setActivePage] = useState(0); const [activePage, setActivePage] = useState(0);
const [nonDebouncedSearch, setNonDebouncedSearch] = useState<string | undefined>(''); const form = useForm({
const [debouncedSearch] = useDebouncedValue<string | undefined>(nonDebouncedSearch, 200); initialValues: {
const { data } = api.user.all.useQuery({ fullTextSearch: '',
page: activePage, role: PossibleRoleFilter[0].id,
search: debouncedSearch, },
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`; const metaTitle = `${t('metaTitle')} • Homarr`;
@@ -51,118 +89,127 @@ const ManageUsersPage = () => {
</Head> </Head>
<Title mb="md">{t('pageTitle')}</Title> <Title mb="md">{t('pageTitle')}</Title>
<Text mb="xl">{t('text')}</Text>
<Flex columnGap={10} justify="end" mb="md"> <Flex columnGap={10} mb="md">
<Autocomplete <TextInput
placeholder="Filter" rightSection={
data={ <IconX
(data?.users.map((user) => user.name).filter((name) => name !== null) as string[]) ?? [] onClick={() => {
form.setFieldValue('fullTextSearch', '');
}}
size="1rem"
/>
} }
variant="filled" style={{
onChange={(value) => { flexGrow: 1,
setNonDebouncedSearch(value);
}} }}
placeholder="Filter"
variant="filled"
{...form.getInputProps('fullTextSearch')}
/> />
<Button <Button
component={Link} component={Link}
leftIcon={<IconPlus size="1rem" />} leftIcon={<IconUserPlus size="1rem" />}
href="/manage/users/create" href="/manage/users/create"
variant="default" color="green"
variant="light"
px="xl"
> >
{t('buttons.create')} {t('buttons.create')}
</Button> </Button>
</Flex> </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> <Table mb="md" withBorder highlightOnHover>
<thead>
<tr>
<th>{t('table.header.user')}</th>
</tr>
</thead>
<tbody> <tbody>
{data.users.map((user, index) => ( {isLoading && (
<tr key={index}> <tr>
<td> <td colSpan={4}>
<Group position="apart"> <Group position="center" p="lg">
<Group spacing="xs"> <Loader variant="dots" />
<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>
)}
</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>
) : (
<Tooltip label={t('tooltips.promoteToAdmin')} withinPortal withArrow>
<ActionIcon
onClick={() => {
openRoleChangeModal({
...user,
type: 'promote',
});
}}
>
<IconUserUp size="1rem" />
</ActionIcon>
</Tooltip>
)}
<Tooltip label={t('tooltips.deleteUser')} withinPortal withArrow>
<ActionIcon
disabled={user.id === sessionData?.user?.id || user.isOwner}
onClick={() => {
openDeleteUserModal(user);
}}
color="red"
variant="light"
>
<IconTrash size="1rem" />
</ActionIcon>
</Tooltip>
</Group>
</Group> </Group>
</td> </td>
</tr> </tr>
))} )}
{data?.users.length === 0 && (
{debouncedSearch && debouncedSearch.length > 0 && data.countPages === 0 && (
<tr> <tr>
<td colSpan={1}> <td colSpan={4}>
<Box p={15}> <Text p="lg" color="dimmed">
<Text>{t('searchDoesntMatch')}</Text> {t('searchDoesntMatch')}
</Box> </Text>
</td> </td>
</tr> </tr>
)} )}
{data?.users.map((user, index) => (
<tr key={index}>
<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">
Owner
</Badge>
)}
{user.isAdmin && (
<Badge color="red" size="sm">
Admin
</Badge>
)}
</Group>
</Grid.Col>
<Grid.Col span={6} p={0}>
{user.email ? (
<Text>{user.email}</Text>
) : (
<Text color="dimmed">No E-Mail</Text>
)}
</Grid.Col>
</Grid>
</td>
<td width="1%">
<Button
component={Link}
href={`/manage/users/${user.id}/edit`}
leftIcon={<IconPencil size="1rem" />}
variant="default"
>
{t('common:edit')}
</Button>
</td>
</tr>
))}
</tbody> </tbody>
</Table> </Table>
</Grid.Col>
<Group position="right" w="100%" px="sm">
<Pagination <Pagination
total={data.countPages}
value={activePage + 1}
onNextPage={() => { onNextPage={() => {
setActivePage((prev) => prev + 1); setActivePage((prev) => prev + 1);
}} }}
@@ -172,9 +219,12 @@ const ManageUsersPage = () => {
onChange={(targetPage) => { onChange={(targetPage) => {
setActivePage(targetPage - 1); setActivePage(targetPage - 1);
}} }}
total={data?.countPages ?? 0}
value={activePage + 1}
withControls
/> />
</> </Group>
)} </Grid>
</ManageLayout> </ManageLayout>
); );
}; };

View File

@@ -1,11 +1,19 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { eq, like, sql } from 'drizzle-orm';
import { and, eq, like, sql } from 'drizzle-orm';
import { z } from 'zod'; 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 { db } from '~/server/db';
import { getTotalUserCountAsync } from '~/server/db/queries/user'; 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 { hashPassword } from '~/utils/security';
import { import {
colorSchemeParser, colorSchemeParser,
@@ -13,9 +21,7 @@ import {
signUpFormSchema, signUpFormSchema,
updateSettingsValidationSchema, updateSettingsValidationSchema,
} from '~/validations/user'; } from '~/validations/user';
import { PossibleRoleFilter } from '~/pages/manage/users';
import { COOKIE_COLOR_SCHEME_KEY, COOKIE_LOCALE_KEY } from '../../../../data/constants';
import { adminProcedure, createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
export const userRouter = createTRPCRouter({ export const userRouter = createTRPCRouter({
createOwnerAccount: publicProcedure.input(signUpFormSchema).mutation(async ({ ctx, input }) => { createOwnerAccount: publicProcedure.input(signUpFormSchema).mutation(async ({ ctx, input }) => {
@@ -34,6 +40,47 @@ export const userRouter = createTRPCRouter({
isOwner: true, 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 () => { count: publicProcedure.query(async () => {
return await getTotalUserCountAsync(); return await getTotalUserCountAsync();
}), }),
@@ -42,8 +89,8 @@ export const userRouter = createTRPCRouter({
signUpFormSchema.and( signUpFormSchema.and(
z.object({ z.object({
inviteToken: z.string(), inviteToken: z.string(),
}) }),
) ),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const invite = await db.query.invites.findFirst({ const invite = await db.query.invites.findFirst({
@@ -75,7 +122,7 @@ export const userRouter = createTRPCRouter({
.input( .input(
z.object({ z.object({
colorScheme: colorSchemeParser, colorScheme: colorSchemeParser,
}) }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
await db await db
@@ -122,7 +169,7 @@ export const userRouter = createTRPCRouter({
.input( .input(
z.object({ z.object({
language: z.string(), language: z.string(),
}) }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
await db await db
@@ -184,24 +231,48 @@ export const userRouter = createTRPCRouter({
z.object({ z.object({
limit: z.number().min(1).max(100).default(10), limit: z.number().min(1).max(100).default(10),
page: z.number().min(0), page: z.number().min(0),
search: z search: z.object({
.string() fullTextSearch: z
.optional() .string()
.transform((value) => (value === '' ? undefined : value)), .optional()
}) .transform((value) => (value === '' ? undefined : value)),
role: z
.string()
.transform((value) => (value.length > 0 ? value : undefined))
.optional(),
}),
}),
) )
.query(async ({ ctx, input }) => { .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 limit = input.limit;
const dbUsers = await db.query.users.findMany({ const dbUsers = await db.query.users.findMany({
limit: limit + 1, limit: limit + 1,
offset: limit * input.page, 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 const countUsers = await db
.select({ count: sql<number>`count(*)` }) .select({ count: sql<number>`count(*)` })
.from(users) .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); .then((rows) => rows[0].count);
return { return {
@@ -213,17 +284,54 @@ export const userRouter = createTRPCRouter({
isOwner: user.isOwner, isOwner: user.isOwner,
})), })),
countPages: Math.ceil(countUsers / limit), 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); 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 deleteUser: adminProcedure
.input( .input(
z.object({ z.object({
id: z.string(), id: z.string(),
}) }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const user = await db.query.users.findFirst({ const user = await db.query.users.findFirst({
@@ -259,7 +367,7 @@ const createUserIfNotPresent = async (
options: { options: {
defaultSettings?: Partial<UserSettings>; defaultSettings?: Partial<UserSettings>;
isOwner?: boolean; isOwner?: boolean;
} | void } | void,
) => { ) => {
const existingUser = await db.query.users.findFirst({ const existingUser = await db.query.users.findFirst({
where: eq(users.name, input.username), where: eq(users.name, input.username),

View File

@@ -42,6 +42,7 @@ export const manageNamespaces = [
'manage/users', 'manage/users',
'manage/users/invites', 'manage/users/invites',
'manage/users/create', 'manage/users/create',
'manage/users/edit'
]; ];
export const loginNamespaces = ['authentication/login']; export const loginNamespaces = ['authentication/login'];