mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-14 01:15:47 +01:00
@@ -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": {
|
||||||
|
|||||||
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({
|
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);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
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 {
|
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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'];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user