Merge branch 'dev' into docker-onboarding-fix
This commit is contained in:
@@ -183,4 +183,9 @@ export const availableIntegrations = [
|
||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/adguard-home.png',
|
||||
label: 'AdGuard Home',
|
||||
},
|
||||
{
|
||||
value: 'homeAssistant',
|
||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/home-assistant.png',
|
||||
label: 'Home Assistant'
|
||||
}
|
||||
] as const satisfies Readonly<SelectItem[]>;
|
||||
|
||||
@@ -207,6 +207,7 @@ const WidgetOptionTypeSwitch: FC<{
|
||||
{info && <InfoCard message={t(`descriptor.settings.${key}.info`)} link={link} />}
|
||||
</Group>
|
||||
<Select
|
||||
searchable
|
||||
defaultValue={option.defaultValue}
|
||||
data={data}
|
||||
value={value as string}
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,12 +4,31 @@ import { useState } from 'react';
|
||||
import { StepCreateAccount } from './step-create-account';
|
||||
import { StepOnboardingFinished } from './step-onboarding-finished';
|
||||
import { StepUpdatePathMappings } from './step-update-path-mappings';
|
||||
import { api } from '~/utils/api';
|
||||
|
||||
export const OnboardingSteps = ({ isUpdate }: { isUpdate: boolean }) => {
|
||||
const maximumSteps = isUpdate ? 3 : 2;
|
||||
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const nextStep = () => setCurrentStep((current) => (current < 3 ? current + 1 : current));
|
||||
|
||||
const nextStep = () => setCurrentStep((current) => {
|
||||
const newValue = (current < maximumSteps ? current + 1 : current);
|
||||
|
||||
if (currentStep + 1 >= maximumSteps) {
|
||||
onFinishOnboarding();
|
||||
}
|
||||
|
||||
return newValue;
|
||||
});
|
||||
|
||||
const prevStep = () => setCurrentStep((current) => (current > 0 ? current - 1 : current));
|
||||
|
||||
const { mutate: mutateConfigSchemaVersion } = api.config.updateConfigurationSchemaToLatest.useMutation();
|
||||
|
||||
const onFinishOnboarding = () => {
|
||||
mutateConfigSchemaVersion();
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack p="lg">
|
||||
<Stepper
|
||||
|
||||
@@ -201,20 +201,14 @@ export const ManageLayout = ({ children }: ManageLayoutProps) => {
|
||||
|
||||
const matchesActive = router.pathname.endsWith(item.href);
|
||||
|
||||
if (item.href.startsWith('http')) {
|
||||
return (
|
||||
<NavLink
|
||||
{...commonItemProps}
|
||||
active={matchesActive}
|
||||
target={item.target}
|
||||
key={index}
|
||||
component="a"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLink {...commonItemProps} active={matchesActive} component={Link} key={index} />
|
||||
<NavLink
|
||||
{...commonItemProps}
|
||||
target={item.target}
|
||||
active={matchesActive}
|
||||
component={Link}
|
||||
key={index}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</NavLink>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
IconSun,
|
||||
IconUserCog,
|
||||
} from '@tabler/icons-react';
|
||||
import { createHash } from 'crypto';
|
||||
import { User } from 'next-auth';
|
||||
import { signOut, useSession } from 'next-auth/react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
@@ -26,66 +27,64 @@ export const AvatarMenu = () => {
|
||||
const defaultBoardHref = useBoardLink('/board');
|
||||
|
||||
return (
|
||||
<>
|
||||
<UnstyledButton>
|
||||
<Menu width={256}>
|
||||
<Menu.Target>
|
||||
<CurrentUserAvatar user={sessionData?.user ?? null} />
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
closeMenuOnClick={false}
|
||||
icon={<Icon size="1rem" />}
|
||||
onClick={toggleColorScheme}
|
||||
>
|
||||
{t('actions.avatar.switchTheme')}
|
||||
</Menu.Item>
|
||||
{sessionData?.user && (
|
||||
<>
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
passHref
|
||||
href="/user/preferences"
|
||||
icon={<IconUserCog size="1rem" />}
|
||||
>
|
||||
{t('actions.avatar.preferences')}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
href={defaultBoardHref}
|
||||
icon={<IconDashboard size="1rem" />}
|
||||
>
|
||||
{t('actions.avatar.defaultBoard')}
|
||||
</Menu.Item>
|
||||
<Menu.Item component={Link} href="/manage" icon={<IconHomeShare size="1rem" />}>
|
||||
{t('actions.avatar.manage')}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
</>
|
||||
)}
|
||||
{sessionData?.user ? (
|
||||
<UnstyledButton>
|
||||
<Menu width={256}>
|
||||
<Menu.Target>
|
||||
<CurrentUserAvatar user={sessionData?.user ?? null} />
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
closeMenuOnClick={false}
|
||||
icon={<Icon size="1rem" />}
|
||||
onClick={toggleColorScheme}
|
||||
>
|
||||
{t('actions.avatar.switchTheme')}
|
||||
</Menu.Item>
|
||||
{sessionData?.user && (
|
||||
<>
|
||||
<Menu.Item
|
||||
icon={<IconLogout size="1rem" />}
|
||||
color="red"
|
||||
onClick={() =>
|
||||
signOut({
|
||||
redirect: false,
|
||||
}).then(() => window.location.reload())
|
||||
}
|
||||
component={Link}
|
||||
passHref
|
||||
href="/user/preferences"
|
||||
icon={<IconUserCog size="1rem" />}
|
||||
>
|
||||
{t('actions.avatar.logout', {
|
||||
username: sessionData.user.name,
|
||||
})}
|
||||
{t('actions.avatar.preferences')}
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<Menu.Item icon={<IconLogin size="1rem" />} component={Link} href="/auth/login">
|
||||
{t('actions.avatar.login')}
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
href={defaultBoardHref}
|
||||
icon={<IconDashboard size="1rem" />}
|
||||
>
|
||||
{t('actions.avatar.defaultBoard')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</UnstyledButton>
|
||||
</>
|
||||
<Menu.Item component={Link} href="/manage" icon={<IconHomeShare size="1rem" />}>
|
||||
{t('actions.avatar.manage')}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
</>
|
||||
)}
|
||||
{sessionData?.user ? (
|
||||
<Menu.Item
|
||||
icon={<IconLogout size="1rem" />}
|
||||
color="red"
|
||||
onClick={() =>
|
||||
signOut({
|
||||
redirect: false,
|
||||
}).then(() => window.location.reload())
|
||||
}
|
||||
>
|
||||
{t('actions.avatar.logout', {
|
||||
username: sessionData.user.name,
|
||||
})}
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<Menu.Item icon={<IconLogin size="1rem" />} component={Link} href="/auth/login">
|
||||
{t('actions.avatar.login')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</UnstyledButton>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -93,12 +92,29 @@ type CurrentUserAvatarProps = {
|
||||
user: User | null;
|
||||
};
|
||||
|
||||
const getGravatar = (email?: string | undefined | null) => {
|
||||
if (!email) return null;
|
||||
const emailHash = createHash('sha256').update(email.trim().toLowerCase()).digest('hex');
|
||||
return `https://gravatar.com/avatar/${emailHash}?d=null`;
|
||||
};
|
||||
|
||||
const CurrentUserAvatar = forwardRef<HTMLDivElement, CurrentUserAvatarProps>(
|
||||
({ user, ...others }, ref) => {
|
||||
const { primaryColor } = useMantineTheme();
|
||||
if (!user) return <Avatar ref={ref} {...others} />;
|
||||
const { fn } = useMantineTheme();
|
||||
const border = fn.variant({ variant: 'default' }).border;
|
||||
|
||||
if (!user)
|
||||
return <Avatar ref={ref} styles={{ root: { border: `1px solid ${border}` } }} {...others} />;
|
||||
|
||||
return (
|
||||
<Avatar ref={ref} color={primaryColor} {...others}>
|
||||
<Avatar
|
||||
ref={ref}
|
||||
color={primaryColor}
|
||||
src={getGravatar(user.email)}
|
||||
styles={{ root: { border: `1px solid ${border}` } }}
|
||||
{...others}
|
||||
>
|
||||
{user.name?.slice(0, 2).toUpperCase()}
|
||||
</Avatar>
|
||||
);
|
||||
|
||||
@@ -229,7 +229,7 @@ export default function CustomizationPage({
|
||||
)}
|
||||
</Transition>
|
||||
</Affix>
|
||||
<Container>
|
||||
<Container pb="6rem">
|
||||
<Paper p="xl" py="sm" mih="100%" withBorder>
|
||||
<Stack>
|
||||
<Group position="apart">
|
||||
|
||||
@@ -61,7 +61,9 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
// Fetch containers if user is admin, otherwise we don't need them
|
||||
try {
|
||||
if (session?.user.isAdmin == true) containers = await caller.containers();
|
||||
} catch (error) {}
|
||||
} catch (error) {
|
||||
|
||||
}
|
||||
return {
|
||||
props: {
|
||||
config,
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
createStyles
|
||||
createStyles,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAnchor,
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
IconLanguage,
|
||||
IconSchema,
|
||||
IconVersions,
|
||||
IconVocabulary
|
||||
IconVocabulary,
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { InitOptions } from 'i18next';
|
||||
@@ -262,7 +262,7 @@ export const Page = ({ contributors }: { contributors: Contributors[] }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export async function getServerSideProps({ locale }: GetServerSidePropsContext) {
|
||||
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
||||
const contributors = (await fetch(
|
||||
`https://api.github.com/repos/${REPO_URL}/contributors?per_page=100`,
|
||||
{
|
||||
@@ -272,7 +272,12 @@ export async function getServerSideProps({ locale }: GetServerSidePropsContext)
|
||||
return {
|
||||
props: {
|
||||
contributors,
|
||||
...(await getServerSideTranslations(['layout/manage', 'manage/index'], locale)),
|
||||
...(await getServerSideTranslations(
|
||||
['layout/manage', 'manage/index'],
|
||||
ctx.locale,
|
||||
ctx.req,
|
||||
ctx.res
|
||||
)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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,118 +89,127 @@ 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[]) ?? []
|
||||
<Flex columnGap={10} mb="md">
|
||||
<TextInput
|
||||
rightSection={
|
||||
<IconX
|
||||
onClick={() => {
|
||||
form.setFieldValue('fullTextSearch', '');
|
||||
}}
|
||||
size="1rem"
|
||||
/>
|
||||
}
|
||||
variant="filled"
|
||||
onChange={(value) => {
|
||||
setNonDebouncedSearch(value);
|
||||
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) => (
|
||||
<tr key={index}>
|
||||
<td>
|
||||
<Group position="apart">
|
||||
<Group spacing="xs">
|
||||
<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>
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td colSpan={4}>
|
||||
<Group position="center" p="lg">
|
||||
<Loader variant="dots" />
|
||||
</Group>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{debouncedSearch && debouncedSearch.length > 0 && data.countPages === 0 && (
|
||||
)}
|
||||
{data?.users.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={1}>
|
||||
<Box p={15}>
|
||||
<Text>{t('searchDoesntMatch')}</Text>
|
||||
</Box>
|
||||
<td colSpan={4}>
|
||||
<Text p="lg" color="dimmed">
|
||||
{t('searchDoesntMatch')}
|
||||
</Text>
|
||||
</td>
|
||||
</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>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -189,9 +239,10 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
||||
const translations = await getServerSideTranslations(
|
||||
manageNamespaces,
|
||||
ctx.locale,
|
||||
undefined,
|
||||
undefined
|
||||
ctx.req,
|
||||
ctx.res
|
||||
);
|
||||
|
||||
return {
|
||||
props: {
|
||||
...translations,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createTRPCRouter } from '~/server/api/trpc';
|
||||
|
||||
import { appRouter } from './routers/app';
|
||||
import { boardRouter } from './routers/board';
|
||||
import { calendarRouter } from './routers/calendar';
|
||||
@@ -16,7 +15,7 @@ import { notebookRouter } from './routers/notebook';
|
||||
import { overseerrRouter } from './routers/overseerr';
|
||||
import { passwordRouter } from './routers/password';
|
||||
import { rssRouter } from './routers/rss';
|
||||
import { timezoneRouter } from './routers/timezone';
|
||||
import { smartHomeEntityStateRouter } from './routers/smart-home/entity-state';
|
||||
import { usenetRouter } from './routers/usenet/router';
|
||||
import { userRouter } from './routers/user';
|
||||
import { weatherRouter } from './routers/weather';
|
||||
@@ -40,13 +39,13 @@ export const rootRouter = createTRPCRouter({
|
||||
mediaRequest: mediaRequestsRouter,
|
||||
mediaServer: mediaServerRouter,
|
||||
overseerr: overseerrRouter,
|
||||
timezone: timezoneRouter,
|
||||
usenet: usenetRouter,
|
||||
weather: weatherRouter,
|
||||
invites: inviteRouter,
|
||||
boards: boardRouter,
|
||||
password: passwordRouter,
|
||||
notebook: notebookRouter,
|
||||
smartHomeEntityState: smartHomeEntityStateRouter
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
@@ -12,6 +12,9 @@ import { boardCustomizationSchema } from '~/validations/boards';
|
||||
import { IRssWidget } from '~/widgets/rss/RssWidgetTile';
|
||||
|
||||
import { adminProcedure, createTRPCRouter, publicProcedure } from '../trpc';
|
||||
import { db } from '~/server/db';
|
||||
import { users } from '~/server/db/schema';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
export const configNameSchema = z.string().regex(/^[a-zA-Z0-9-_]+$/);
|
||||
|
||||
@@ -20,14 +23,14 @@ export const configRouter = createTRPCRouter({
|
||||
.input(
|
||||
z.object({
|
||||
name: configNameSchema,
|
||||
})
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
if (input.name.toLowerCase() === 'default') {
|
||||
Consola.error("Rejected config deletion because default configuration can't be deleted");
|
||||
Consola.error('Rejected config deletion because default configuration can\'t be deleted');
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: "Default config can't be deleted",
|
||||
message: 'Default config can\'t be deleted',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -44,7 +47,7 @@ export const configRouter = createTRPCRouter({
|
||||
// If the target is not in the list of files, return an error
|
||||
if (!matchedFile) {
|
||||
Consola.error(
|
||||
`Rejected config deletion request because config name '${input.name}' was not included in present configurations`
|
||||
`Rejected config deletion request because config name '${input.name}' was not included in present configurations`,
|
||||
);
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
@@ -64,7 +67,7 @@ export const configRouter = createTRPCRouter({
|
||||
z.object({
|
||||
name: configNameSchema,
|
||||
config: z.custom<ConfigType>((x) => !!x && typeof x === 'object'),
|
||||
})
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
Consola.info(`Saving updated configuration of '${input.name}' config.`);
|
||||
@@ -96,16 +99,16 @@ export const configRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
const previousApp = previousConfig.apps.find(
|
||||
(previousApp) => previousApp.id === app.id
|
||||
(previousApp) => previousApp.id === app.id,
|
||||
);
|
||||
|
||||
const previousProperty = previousApp?.integration?.properties.find(
|
||||
(previousProperty) => previousProperty.field === property.field
|
||||
(previousProperty) => previousProperty.field === property.field,
|
||||
);
|
||||
|
||||
if (property.value !== undefined && property.value !== null) {
|
||||
Consola.info(
|
||||
'Detected credential change of private secret. Value will be overwritten in configuration'
|
||||
'Detected credential change of private secret. Value will be overwritten in configuration',
|
||||
);
|
||||
return {
|
||||
field: property.field,
|
||||
@@ -165,7 +168,7 @@ export const configRouter = createTRPCRouter({
|
||||
.input(
|
||||
z.object({
|
||||
name: configNameSchema,
|
||||
})
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (!configExists(input.name)) {
|
||||
@@ -223,4 +226,21 @@ export const configRouter = createTRPCRouter({
|
||||
const targetPath = path.join('data/configs', `${input.name}.json`);
|
||||
fs.writeFileSync(targetPath, JSON.stringify(newConfig, null, 2), 'utf8');
|
||||
}),
|
||||
// publicProcedure is not optimal, but should be fince, since there is no input and output data nor can you break the config
|
||||
updateConfigurationSchemaToLatest: publicProcedure.mutation(async () => {
|
||||
const files = fs.readdirSync('./data/configs').filter((file) => file.endsWith('.json'));
|
||||
|
||||
console.log('updating the schema version of', files.length, 'configurations');
|
||||
|
||||
for (const file of files) {
|
||||
const name = file.replace('.json', '');
|
||||
const config = await getFrontendConfig(name);
|
||||
|
||||
config.schemaVersion = 2;
|
||||
const targetPath = `data/configs/${name}.json`;
|
||||
fs.writeFileSync(targetPath, JSON.stringify(config, null, 2), 'utf8');
|
||||
|
||||
console.log('updated', name, 'to schema version', config.schemaVersion);
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -39,10 +39,10 @@ const rssFeedResultObjectSchema = z
|
||||
title: z.string(),
|
||||
content: z.string(),
|
||||
pubDate: z.string().optional(),
|
||||
})
|
||||
}),
|
||||
),
|
||||
}),
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
export const rssRouter = createTRPCRouter({
|
||||
@@ -52,7 +52,7 @@ export const rssRouter = createTRPCRouter({
|
||||
widgetId: z.string().uuid(),
|
||||
feedUrls: z.array(z.string()),
|
||||
configName: z.string(),
|
||||
})
|
||||
}),
|
||||
)
|
||||
.output(z.array(rssFeedResultObjectSchema))
|
||||
.query(async ({ input }) => {
|
||||
@@ -75,8 +75,8 @@ export const rssRouter = createTRPCRouter({
|
||||
|
||||
const result = await Promise.all(
|
||||
input.feedUrls.map(async (feedUrl) =>
|
||||
getFeedUrl(feedUrl, rssWidget.properties.dangerousAllowSanitizedItemContent)
|
||||
)
|
||||
getFeedUrl(feedUrl, rssWidget.properties.dangerousAllowSanitizedItemContent),
|
||||
),
|
||||
);
|
||||
|
||||
return result;
|
||||
@@ -106,11 +106,11 @@ const getFeedUrl = async (feedUrl: string, dangerousAllowSanitizedItemContent: b
|
||||
title: item.title ? decode(item.title) : undefined,
|
||||
content: processItemContent(
|
||||
item['content:encoded'] ?? item.content,
|
||||
dangerousAllowSanitizedItemContent
|
||||
dangerousAllowSanitizedItemContent,
|
||||
),
|
||||
enclosure: createEnclosure(item),
|
||||
link: createLink(item),
|
||||
})
|
||||
}),
|
||||
)
|
||||
.sort((a: { pubDate: number }, b: { pubDate: number }) => {
|
||||
if (!a.pubDate || !b.pubDate) {
|
||||
@@ -159,7 +159,9 @@ const processItemContent = (content: string, dangerousAllowSanitizedItemContent:
|
||||
});
|
||||
}
|
||||
|
||||
return encode(content);
|
||||
return encode(content, {
|
||||
level: "html5"
|
||||
});
|
||||
};
|
||||
|
||||
const createLink = (item: any) => {
|
||||
|
||||
53
src/server/api/routers/smart-home/entity-state.ts
Normal file
53
src/server/api/routers/smart-home/entity-state.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
import { ZodError, z } from 'zod';
|
||||
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from '../../trpc';
|
||||
|
||||
import { findAppProperty } from '~/tools/client/app-properties';
|
||||
import { getConfig } from '~/tools/config/getConfig';
|
||||
import { HomeAssistantSingleton } from '~/tools/singleton/HomeAssistantSingleton';
|
||||
|
||||
export const smartHomeEntityStateRouter = createTRPCRouter({
|
||||
retrieveStatus: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
configName: z.string(),
|
||||
entityId: z.string().regex(/^[A-Za-z0-9-_\.]+$/)
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const config = getConfig(input.configName);
|
||||
|
||||
const instances = config.apps.filter((app) => app.integration?.type == 'homeAssistant');
|
||||
|
||||
for (var instance of instances) {
|
||||
const url = new URL(instance.url);
|
||||
const client = HomeAssistantSingleton.getOrSet(url, findAppProperty(instance, 'apiKey'));
|
||||
const state = await client.getEntityState(input.entityId);
|
||||
|
||||
if (!state.success) {
|
||||
if (!(state.error instanceof ZodError)) {
|
||||
continue;
|
||||
}
|
||||
// Consola.error('Unable to handle entity state: ', state.error);
|
||||
throw new TRPCError({
|
||||
code: 'NOT_IMPLEMENTED',
|
||||
message: `Unable to handle Home Assistant entity state. This may be due to malformed response or unknown entity type. Check log for details`
|
||||
});
|
||||
}
|
||||
|
||||
if(!state.data) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: `Home Assistant: Unable to connect to app '${instance.id}'. Check logs for details`
|
||||
});
|
||||
}
|
||||
|
||||
return state.data;
|
||||
}
|
||||
|
||||
return null;
|
||||
}),
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||
|
||||
const GeoTz = require('browser-geo-tz/dist/geotz.js');
|
||||
|
||||
export const timezoneRouter = createTRPCRouter({
|
||||
at: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
longitude: z.number(),
|
||||
latitude: z.number(),
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const timezone = await GeoTz.find(input.latitude, input.longitude);
|
||||
return Array.isArray(timezone) ? timezone[0] : timezone;
|
||||
}),
|
||||
});
|
||||
@@ -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
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => (value === '' ? undefined : value)),
|
||||
})
|
||||
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),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { SelectItem } from '@mantine/core';
|
||||
|
||||
export const StatusCodes: SelectItem[] = [
|
||||
{ value: '200', label: '200 - OK', group: 'Sucessful responses' },
|
||||
{ value: '202', label: '202 - Accepted', group: 'Sucessful responses' },
|
||||
{ value: '204', label: '204 - No Content', group: 'Sucessful responses' },
|
||||
{ value: '301', label: '301 - Moved Permanently', group: 'Redirection responses' },
|
||||
{ value: '302', label: '302 - Found / Moved Temporarily', group: 'Redirection responses' },
|
||||
|
||||
41
src/tools/server/sdk/homeassistant/HomeAssistant.ts
Normal file
41
src/tools/server/sdk/homeassistant/HomeAssistant.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import Consola from 'consola';
|
||||
import { appendPath } from '~/tools/shared/strings';
|
||||
import { entityStateSchema } from './models/EntityState';
|
||||
|
||||
export class HomeAssistant {
|
||||
public readonly basePath: URL;
|
||||
private readonly token: string;
|
||||
|
||||
constructor(url: URL, token: string) {
|
||||
if (!url.pathname.endsWith('/')) {
|
||||
url.pathname += "/";
|
||||
}
|
||||
url.pathname += 'api';
|
||||
this.basePath = url;
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
async getEntityState(entityId: string) {
|
||||
try {
|
||||
const response = await fetch(appendPath(this.basePath, `/states/${entityId}`), {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.token}`
|
||||
}
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false as const,
|
||||
error: body
|
||||
};
|
||||
}
|
||||
return entityStateSchema.safeParseAsync(body);
|
||||
} catch (err) {
|
||||
Consola.error(`Failed to fetch from '${this.basePath}': ${err}`);
|
||||
return {
|
||||
success: false as const,
|
||||
error: err
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/tools/server/sdk/homeassistant/models/EntityState.ts
Normal file
12
src/tools/server/sdk/homeassistant/models/EntityState.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
|
||||
export const entityStateSchema = z.object({
|
||||
attributes: z.record(z.union([z.string(), z.number(), z.boolean(), z.null()])),
|
||||
entity_id: z.string(),
|
||||
last_changed: z.string().pipe(z.coerce.date()),
|
||||
last_updated: z.string().pipe(z.coerce.date()),
|
||||
state: z.string(),
|
||||
});
|
||||
|
||||
export type EntityState = z.infer<typeof entityStateSchema>;
|
||||
@@ -30,6 +30,7 @@ export const boardNamespaces = [
|
||||
'modules/dns-hole-controls',
|
||||
'modules/bookmark',
|
||||
'modules/notebook',
|
||||
'modules/smart-home/entity-state',
|
||||
'widgets/error-boundary',
|
||||
'widgets/draggable-list',
|
||||
'widgets/location',
|
||||
@@ -42,6 +43,7 @@ export const manageNamespaces = [
|
||||
'manage/users',
|
||||
'manage/users/invites',
|
||||
'manage/users/create',
|
||||
'manage/users/edit'
|
||||
];
|
||||
export const loginNamespaces = ['authentication/login'];
|
||||
|
||||
|
||||
@@ -12,3 +12,9 @@ export const trimStringEnding = (original: string, toTrimIfExists: string[]) =>
|
||||
export const firstUpperCase = (str: string) => {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
};
|
||||
|
||||
export const appendPath = (url: URL, path: string) => {
|
||||
const newUrl = new URL(url);
|
||||
newUrl.pathname += path;
|
||||
return newUrl;
|
||||
}
|
||||
|
||||
20
src/tools/singleton/HomeAssistantSingleton.ts
Normal file
20
src/tools/singleton/HomeAssistantSingleton.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { HomeAssistant } from '../server/sdk/homeassistant/HomeAssistant';
|
||||
|
||||
export class HomeAssistantSingleton {
|
||||
private static _instances: HomeAssistant[] = [];
|
||||
|
||||
public static getOrSet(url: URL, token: string): HomeAssistant {
|
||||
const match = this._instances.find(
|
||||
(instance) =>
|
||||
instance.basePath.hostname === url.hostname && instance.basePath.port === url.port
|
||||
);
|
||||
|
||||
if (!match) {
|
||||
const instance = new HomeAssistant(url, token);
|
||||
this._instances.push(instance);
|
||||
return instance;
|
||||
}
|
||||
|
||||
return match;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Icon, IconKey, IconPassword, IconUser } from '@tabler/icons-react';
|
||||
|
||||
import { Property } from 'csstype';
|
||||
|
||||
import { TileBaseType } from './tile';
|
||||
@@ -55,7 +56,8 @@ export type IntegrationType =
|
||||
| 'jellyfin'
|
||||
| 'nzbGet'
|
||||
| 'pihole'
|
||||
| 'adGuardHome';
|
||||
| 'adGuardHome'
|
||||
| 'homeAssistant';
|
||||
|
||||
export type AppIntegrationType = {
|
||||
type: IntegrationType | null;
|
||||
@@ -97,6 +99,7 @@ export const integrationFieldProperties: {
|
||||
plex: ['apiKey'],
|
||||
pihole: ['apiKey'],
|
||||
adGuardHome: ['username', 'password'],
|
||||
homeAssistant: ['apiKey']
|
||||
};
|
||||
|
||||
export type IntegrationFieldDefinitionType = {
|
||||
|
||||
@@ -8,10 +8,8 @@ import utc from 'dayjs/plugin/utc';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getLanguageByCode } from '~/tools/language';
|
||||
import { api } from '~/utils/api';
|
||||
|
||||
import { defineWidget } from '../helper';
|
||||
import { WidgetLoading } from '../loading';
|
||||
import { IWidget } from '../widgets';
|
||||
|
||||
dayjs.extend(advancedFormat);
|
||||
@@ -22,6 +20,17 @@ const definition = defineWidget({
|
||||
id: 'date',
|
||||
icon: IconClock,
|
||||
options: {
|
||||
timezone: {
|
||||
type: 'select',
|
||||
data: () => Intl.supportedValuesOf('timeZone').map((value) => ({ value, label: value })),
|
||||
defaultValue: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
info: true,
|
||||
infoLink: "https://www.timeanddate.com/time/map/",
|
||||
},
|
||||
customTitle: {
|
||||
type: 'text',
|
||||
defaultValue: '',
|
||||
},
|
||||
display24HourFormat: {
|
||||
type: 'switch',
|
||||
defaultValue: false,
|
||||
@@ -41,18 +50,6 @@ const definition = defineWidget({
|
||||
{ value: 'MM/DD', label: dayjs().format('MM/DD') },
|
||||
],
|
||||
},
|
||||
enableTimezone: {
|
||||
type: 'switch',
|
||||
defaultValue: false,
|
||||
},
|
||||
timezoneLocation: {
|
||||
type: 'location',
|
||||
defaultValue: {
|
||||
name: 'Paris',
|
||||
latitude: 48.85341,
|
||||
longitude: 2.3488,
|
||||
},
|
||||
},
|
||||
titleState: {
|
||||
type: 'select',
|
||||
defaultValue: 'both',
|
||||
@@ -81,16 +78,7 @@ function DateTile({ widget }: DateTileProps) {
|
||||
const { cx, classes } = useStyles();
|
||||
const { data: sessionData } = useSession();
|
||||
const [now, setDate] = useState(new Date());
|
||||
const { data, isFetching } = api.timezone.at.useQuery(
|
||||
{
|
||||
latitude: widget.properties.timezoneLocation.latitude,
|
||||
longitude: widget.properties.timezoneLocation.longitude,
|
||||
},
|
||||
{
|
||||
enabled: location !== undefined && widget.properties.enableTimezone,
|
||||
retry: false,
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Refresh the time every second
|
||||
const interval = setInterval(() => setDate(new Date()), 1000);
|
||||
@@ -100,22 +88,21 @@ function DateTile({ widget }: DateTileProps) {
|
||||
const language = getLanguageByCode(sessionData?.user.language ?? 'en');
|
||||
dayjs.locale(language.locale);
|
||||
|
||||
if (isFetching) return <WidgetLoading />;
|
||||
|
||||
return (
|
||||
<Stack ref={ref} className={cx(classes.wrapper, 'dashboard-tile-clock-wrapper')}>
|
||||
{widget.properties.enableTimezone && widget.properties.titleState !== 'none' && (
|
||||
<Text
|
||||
size={width < 150 ? 'sm' : 'lg'}
|
||||
className={cx(classes.extras, 'dashboard-tile-clock-city')}
|
||||
>
|
||||
{isFetching}
|
||||
{widget.properties.timezoneLocation.name}
|
||||
{widget.properties.titleState === 'both' && dayjs(now).tz(data).format(' (z)')}
|
||||
</Text>
|
||||
)}
|
||||
{widget.properties.titleState !== 'none' &&
|
||||
(widget.properties.customTitle.length > 0 || widget.properties.titleState === 'both') && (
|
||||
<Text
|
||||
size={width < 150 ? 'sm' : 'lg'}
|
||||
className={cx(classes.extras, 'dashboard-tile-clock-city')}
|
||||
>
|
||||
{widget.properties.customTitle.length > 0 && widget.properties.customTitle}
|
||||
{widget.properties.titleState === 'both' &&
|
||||
dayjs(now).tz(widget.properties.timezone).format(' (z)')}
|
||||
</Text>
|
||||
)}
|
||||
<Text className={cx(classes.clock, 'dashboard-tile-clock-hour')}>
|
||||
{dayjs(now).tz(data).format(formatString)}
|
||||
{dayjs(now).tz(widget.properties.timezone).format(formatString)}
|
||||
</Text>
|
||||
{!widget.properties.dateFormat.includes('hide') && (
|
||||
<Text
|
||||
@@ -123,7 +110,7 @@ function DateTile({ widget }: DateTileProps) {
|
||||
pt="0.2rem"
|
||||
className={cx(classes.extras, 'dashboard-tile-clock-date')}
|
||||
>
|
||||
{dayjs(now).tz(data).format(widget.properties.dateFormat)}
|
||||
{dayjs(now).tz(widget.properties.timezone).format(widget.properties.dateFormat)}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -11,6 +11,7 @@ import mediaRequestsStats from './media-requests/MediaRequestStatsTile';
|
||||
import mediaServer from './media-server/MediaServerTile';
|
||||
import notebook from './notebook/NotebookWidgetTile';
|
||||
import rss from './rss/RssWidgetTile';
|
||||
import smartHomeEntityState from './smart-home/entity-state/entity-state.widget';
|
||||
import torrent from './torrent/TorrentTile';
|
||||
import usenet from './useNet/UseNetTile';
|
||||
import videoStream from './video/VideoStreamTile';
|
||||
@@ -34,4 +35,5 @@ export default {
|
||||
'dns-hole-controls': dnsHoleControls,
|
||||
bookmark,
|
||||
notebook,
|
||||
'smart-home/entity-state': smartHomeEntityState
|
||||
};
|
||||
|
||||
98
src/widgets/smart-home/entity-state/entity-state.widget.tsx
Normal file
98
src/widgets/smart-home/entity-state/entity-state.widget.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Center, Loader, Stack, Text, Tooltip } from '@mantine/core';
|
||||
import { IconAlertHexagon, IconBinaryTree, IconExclamationMark } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { api } from '~/utils/api';
|
||||
import { defineWidget } from '~/widgets/helper';
|
||||
import { WidgetLoading } from '~/widgets/loading';
|
||||
import { IWidget } from '~/widgets/widgets';
|
||||
|
||||
const definition = defineWidget({
|
||||
id: 'smart-home/entity-state',
|
||||
icon: IconBinaryTree,
|
||||
options: {
|
||||
entityId: {
|
||||
type: 'text',
|
||||
defaultValue: 'sun.sun',
|
||||
info: true,
|
||||
},
|
||||
displayName: {
|
||||
type: 'text',
|
||||
defaultValue: 'Sun',
|
||||
},
|
||||
},
|
||||
gridstack: {
|
||||
minWidth: 1,
|
||||
minHeight: 1,
|
||||
maxWidth: 12,
|
||||
maxHeight: 12,
|
||||
},
|
||||
component: EntityStateTile,
|
||||
});
|
||||
|
||||
export type ISmartHomeEntityStateWidget = IWidget<(typeof definition)['id'], typeof definition>;
|
||||
|
||||
interface SmartHomeEntityStateWidgetProps {
|
||||
widget: ISmartHomeEntityStateWidget;
|
||||
}
|
||||
|
||||
function EntityStateTile({ widget }: SmartHomeEntityStateWidgetProps) {
|
||||
const { t } = useTranslation('modules/smart-home/entity-state');
|
||||
const { name: configName } = useConfigContext();
|
||||
|
||||
const { data, isInitialLoading, isLoading, isError, error } =
|
||||
api.smartHomeEntityState.retrieveStatus.useQuery(
|
||||
{
|
||||
configName: configName!,
|
||||
entityId: widget.properties.entityId,
|
||||
},
|
||||
{
|
||||
enabled: !!configName,
|
||||
refetchInterval: 2 * 60 * 1000
|
||||
}
|
||||
);
|
||||
|
||||
let dataComponent = null;
|
||||
|
||||
if (isError) {
|
||||
dataComponent = (
|
||||
<Tooltip label={error.message} withArrow withinPortal>
|
||||
<IconAlertHexagon color="red" />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (!dataComponent && isInitialLoading) {
|
||||
dataComponent = <WidgetLoading />;
|
||||
}
|
||||
|
||||
if (!dataComponent && !data) {
|
||||
dataComponent = (
|
||||
<Tooltip label={t('entityNotFound')} withArrow withinPortal>
|
||||
<IconExclamationMark color="red" />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (!dataComponent) {
|
||||
dataComponent = (
|
||||
<Text align="center">
|
||||
{data?.state}
|
||||
{isLoading && <Loader ml="xs" size={10} />}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Center h="100%" w="100%">
|
||||
<Stack align="center" spacing={3}>
|
||||
<Text align="center" weight="bold" size="lg">
|
||||
{widget.properties.displayName}
|
||||
</Text>
|
||||
{dataComponent}
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
export default definition;
|
||||
@@ -6,12 +6,11 @@ import {
|
||||
Group,
|
||||
List,
|
||||
MantineColor,
|
||||
Popover,
|
||||
Progress,
|
||||
Stack,
|
||||
Text,
|
||||
createStyles,
|
||||
useMantineTheme,
|
||||
useMantineTheme
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAffiliate,
|
||||
@@ -24,8 +23,6 @@ import {
|
||||
IconUpload,
|
||||
} from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { MIN_WIDTH_MOBILE } from '~/constants/constants';
|
||||
import { calculateETA } from '~/tools/client/calculateEta';
|
||||
import { humanFileSize } from '~/tools/humanFileSize';
|
||||
import { AppType } from '~/types/app';
|
||||
|
||||
@@ -35,89 +32,7 @@ interface TorrentQueueItemProps {
|
||||
width: number;
|
||||
}
|
||||
|
||||
export const BitTorrentQueueItem = ({ torrent, width, app }: TorrentQueueItemProps) => {
|
||||
const { classes } = useStyles();
|
||||
const { t } = useTranslation('modules/torrents-status');
|
||||
|
||||
const size = torrent.totalSelected;
|
||||
return (
|
||||
<Popover
|
||||
withArrow
|
||||
withinPortal
|
||||
radius="lg"
|
||||
shadow="sm"
|
||||
transitionProps={{
|
||||
transition: 'pop',
|
||||
}}
|
||||
>
|
||||
<Popover.Target>
|
||||
<tr key={torrent.id} style={{ cursor: 'pointer' }}>
|
||||
<td>
|
||||
<Text
|
||||
style={{
|
||||
maxWidth: '30vw',
|
||||
}}
|
||||
size="xs"
|
||||
lineClamp={1}
|
||||
>
|
||||
{torrent.name}
|
||||
</Text>
|
||||
{app && (
|
||||
<Text size="xs" color="dimmed">
|
||||
{t('card.table.item.text', {
|
||||
appName: app.name,
|
||||
ratio: torrent.ratio.toFixed(2),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<Text className={classes.noTextBreak} size="xs">
|
||||
{humanFileSize(size, false)}
|
||||
</Text>
|
||||
</td>
|
||||
{width > MIN_WIDTH_MOBILE && (
|
||||
<td>
|
||||
<Text className={classes.noTextBreak} size="xs">
|
||||
{torrent.downloadSpeed > 0 ? `${humanFileSize(torrent.downloadSpeed,false)}/s` : '-'}
|
||||
</Text>
|
||||
</td>
|
||||
)}
|
||||
{width > MIN_WIDTH_MOBILE && (
|
||||
<td>
|
||||
<Text className={classes.noTextBreak} size="xs">
|
||||
{torrent.uploadSpeed > 0 ? `${humanFileSize(torrent.uploadSpeed,false)}/s` : '-'}
|
||||
</Text>
|
||||
</td>
|
||||
)}
|
||||
{width > MIN_WIDTH_MOBILE && (
|
||||
<td>
|
||||
<Text className={classes.noTextBreak} size="xs">
|
||||
{torrent.eta <= 0 ? '∞' : calculateETA(torrent.eta)}
|
||||
</Text>
|
||||
</td>
|
||||
)}
|
||||
<td>
|
||||
<Text className={classes.noTextBreak}>{(torrent.progress * 100).toFixed(1)}%</Text>
|
||||
<Progress
|
||||
radius="lg"
|
||||
color={
|
||||
torrent.progress === 1 ? 'green' : torrent.state === 'paused' ? 'yellow' : 'blue'
|
||||
}
|
||||
value={torrent.progress * 100}
|
||||
size="lg"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<TorrentQueuePopover torrent={torrent} app={app} />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const TorrentQueuePopover = ({ torrent, app }: Omit<TorrentQueueItemProps, 'width'>) => {
|
||||
export const TorrentQueuePopover = ({ torrent, app }: Omit<TorrentQueueItemProps, 'width'>) => {
|
||||
const { t } = useTranslation('modules/torrents-status');
|
||||
const { colors } = useMantineTheme();
|
||||
|
||||
|
||||
@@ -1,31 +1,42 @@
|
||||
import { NormalizedTorrent, TorrentState } from '@ctrl/shared-torrent';
|
||||
import {
|
||||
MRT_Table,
|
||||
useMantineReactTable,
|
||||
type MRT_ColumnDef,
|
||||
} from 'mantine-react-table';
|
||||
|
||||
import { NormalizedTorrent } from '@ctrl/shared-torrent';
|
||||
import {
|
||||
Badge,
|
||||
Center,
|
||||
createStyles,
|
||||
Flex,
|
||||
Group,
|
||||
Loader,
|
||||
Popover,
|
||||
Progress,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { useElementSize } from '@mantine/hooks';
|
||||
import { IconFileDownload, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { IconFileDownload } from '@tabler/icons-react';
|
||||
import dayjs from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useMemo } from 'react';
|
||||
import { useCardStyles } from '~/components/layout/Common/useCardStyles';
|
||||
import { MIN_WIDTH_MOBILE } from '~/constants/constants';
|
||||
import { calculateETA } from '~/tools/client/calculateEta';
|
||||
import { humanFileSize } from '~/tools/humanFileSize';
|
||||
import { NormalizedDownloadQueueResponse } from '~/types/api/downloads/queue/NormalizedDownloadQueueResponse';
|
||||
import { AppIntegrationType } from '~/types/app';
|
||||
|
||||
import { useGetDownloadClientsQueue } from '../download-speed/useGetNetworkSpeed';
|
||||
import { defineWidget } from '../helper';
|
||||
import { IWidget } from '../widgets';
|
||||
import { BitTorrentQueueItem } from './TorrentQueueItem';
|
||||
import { TorrentQueuePopover } from './TorrentQueueItem';
|
||||
|
||||
dayjs.extend(duration);
|
||||
dayjs.extend(relativeTime);
|
||||
@@ -44,7 +55,8 @@ const definition = defineWidget({
|
||||
type: 'switch',
|
||||
defaultValue: true,
|
||||
},
|
||||
speedLimitOfActiveTorrents: { // Unit : kB/s
|
||||
speedLimitOfActiveTorrents: {
|
||||
// Unit : kB/s
|
||||
type: 'number',
|
||||
defaultValue: 10,
|
||||
},
|
||||
@@ -98,6 +110,137 @@ function TorrentTile({ widget }: TorrentTileProps) {
|
||||
dataUpdatedAt: number;
|
||||
} = useGetDownloadClientsQueue();
|
||||
|
||||
let torrents: NormalizedTorrent[] = [];
|
||||
if(!(isError || !data || data.apps.length === 0 || Object.values(data.apps).length < 1)) {
|
||||
torrents = data.apps.flatMap((app) => (app.type === 'torrent' ? app.torrents : []))
|
||||
}
|
||||
|
||||
const filteredTorrents = filterTorrents(widget, torrents);
|
||||
|
||||
|
||||
const difference = new Date().getTime() - dataUpdatedAt;
|
||||
const duration = dayjs.duration(difference, 'ms');
|
||||
const humanizedDuration = duration.humanize();
|
||||
|
||||
const ratioGlobal = getTorrentsRatio(widget, torrents, false);
|
||||
const ratioWithFilter = getTorrentsRatio(widget, torrents, true);
|
||||
|
||||
const columns = useMemo<MRT_ColumnDef<NormalizedTorrent>[]>(() => [
|
||||
{
|
||||
id: "dateAdded",
|
||||
accessorFn: (row) => new Date(row.dateAdded),
|
||||
header: "dateAdded",
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: t('card.table.header.name'),
|
||||
Cell: ({ cell, row }) => (
|
||||
<Popover
|
||||
withArrow
|
||||
withinPortal
|
||||
radius="lg"
|
||||
shadow="sm"
|
||||
transitionProps={{
|
||||
transition: 'pop',
|
||||
}}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Text
|
||||
style={{
|
||||
maxWidth: '30vw',
|
||||
}}
|
||||
size="xs"
|
||||
lineClamp={1}
|
||||
>
|
||||
{String(cell.getValue())}
|
||||
</Text>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<TorrentQueuePopover torrent={row.original} app={undefined} />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'totalSize',
|
||||
header: t('card.table.header.size'),
|
||||
Cell: ({ cell }) => formatSize(Number(cell.getValue())),
|
||||
sortDescFirst: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'uploadSpeed',
|
||||
header: t('card.table.header.upload'),
|
||||
Cell: ({ cell }) => formatSpeed(Number(cell.getValue())),
|
||||
sortDescFirst: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'downloadSpeed',
|
||||
header: t('card.table.header.download'),
|
||||
Cell: ({ cell }) => formatSpeed(Number(cell.getValue())),
|
||||
sortDescFirst: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'eta',
|
||||
header: t('card.table.header.estimatedTimeOfArrival'),
|
||||
Cell: ({ cell }) => formatETA(Number(cell.getValue())),
|
||||
sortDescFirst: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'progress',
|
||||
header: t('card.table.header.progress'),
|
||||
Cell: ({ cell, row }) => (
|
||||
<Flex>
|
||||
<Text className={useStyles().classes.noTextBreak}>{(Number(cell.getValue()) * 100).toFixed(1)}%</Text>
|
||||
<Progress
|
||||
radius="lg"
|
||||
color={
|
||||
Number(cell.getValue()) === 1 ? 'green' : row.original.state === 'paused' ? 'yellow' : 'blue'
|
||||
}
|
||||
value={Number(cell.getValue()) * 100}
|
||||
size="lg"
|
||||
/>,
|
||||
</Flex>),
|
||||
sortDescFirst: true,
|
||||
},
|
||||
], []);
|
||||
|
||||
const torrentsTable = useMantineReactTable({
|
||||
columns,
|
||||
data: filteredTorrents,
|
||||
enablePagination: false,
|
||||
enableBottomToolbar: false,
|
||||
enableMultiSort: true,
|
||||
enableColumnActions: false,
|
||||
enableColumnFilters: false,
|
||||
enableSorting: true,
|
||||
initialState: {
|
||||
showColumnFilters: false,
|
||||
showGlobalFilter: false,
|
||||
density: 'xs',
|
||||
sorting: [{ id: 'dateAdded', desc: true }],
|
||||
columnVisibility: {
|
||||
isCompleted: false,
|
||||
dateAdded: false,
|
||||
uploadSpeed: false,
|
||||
downloadSpeed: false,
|
||||
eta: false,
|
||||
},
|
||||
},
|
||||
state: {
|
||||
showColumnFilters: false,
|
||||
showGlobalFilter: false,
|
||||
density: 'xs',
|
||||
columnVisibility: {
|
||||
isCompleted: false,
|
||||
dateAdded: false,
|
||||
uploadSpeed: width > MIN_WIDTH_MOBILE,
|
||||
downloadSpeed: width > MIN_WIDTH_MOBILE,
|
||||
eta: width > MIN_WIDTH_MOBILE,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Stack>
|
||||
@@ -146,51 +289,10 @@ function TorrentTile({ widget }: TorrentTileProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const torrents = data.apps.flatMap((app) => (app.type === 'torrent' ? app.torrents : []));
|
||||
const filteredTorrents = filterTorrents(widget, torrents);
|
||||
|
||||
const difference = new Date().getTime() - dataUpdatedAt;
|
||||
const duration = dayjs.duration(difference, 'ms');
|
||||
const humanizedDuration = duration.humanize();
|
||||
|
||||
const ratioGlobal = getTorrentsRatio(widget, torrents, false);
|
||||
const ratioWithFilter = getTorrentsRatio(widget, torrents, true);
|
||||
|
||||
return (
|
||||
<Flex direction="column" sx={{ height: '100%' }} ref={ref}>
|
||||
<ScrollArea sx={{ height: '100%', width: '100%' }} mb="xs">
|
||||
<Table striped highlightOnHover p="sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('card.table.header.name')}</th>
|
||||
<th>{t('card.table.header.size')}</th>
|
||||
{width > MIN_WIDTH_MOBILE && <th>{t('card.table.header.download')}</th>}
|
||||
{width > MIN_WIDTH_MOBILE && <th>{t('card.table.header.upload')}</th>}
|
||||
{width > MIN_WIDTH_MOBILE && <th>{t('card.table.header.estimatedTimeOfArrival')}</th>}
|
||||
<th>{t('card.table.header.progress')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredTorrents.map((torrent, index) => (
|
||||
<BitTorrentQueueItem key={index} torrent={torrent} width={width} app={undefined} />
|
||||
))}
|
||||
|
||||
{filteredTorrents.length !== torrents.length && (
|
||||
<tr className={classes.card}>
|
||||
<td colSpan={width > MIN_WIDTH_MOBILE ? 6 : 3}>
|
||||
<Flex gap="xs" align="center" justify="center">
|
||||
<IconInfoCircle opacity={0.7} size={18} />
|
||||
<Text align="center" color="dimmed">
|
||||
{t('card.table.body.filterHidingItems', {
|
||||
count: torrents.length - filteredTorrents.length,
|
||||
})}
|
||||
</Text>
|
||||
</Flex>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</Table>
|
||||
<ScrollArea>
|
||||
<MRT_Table table={torrentsTable} />
|
||||
</ScrollArea>
|
||||
<Group spacing="sm">
|
||||
{data.apps.some((x) => !x.success) && (
|
||||
@@ -198,9 +300,8 @@ function TorrentTile({ widget }: TorrentTileProps) {
|
||||
{t('card.footer.error')}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Text color="dimmed" size="xs">
|
||||
{t('card.footer.lastUpdated', { time: humanizedDuration })}
|
||||
{t('card.footer.lastUpdated', { time: humanizedDuration })}
|
||||
{` - ${t('card.footer.ratioGlobal')} : ${
|
||||
ratioGlobal === -1 ? '∞' : ratioGlobal.toFixed(2)
|
||||
}`}
|
||||
@@ -217,7 +318,12 @@ function TorrentTile({ widget }: TorrentTileProps) {
|
||||
export const filterTorrents = (widget: ITorrent, torrents: NormalizedTorrent[]) => {
|
||||
let result = torrents;
|
||||
if (!widget.properties.displayCompletedTorrents) {
|
||||
result = result.filter((torrent) => !torrent.isCompleted || (widget.properties.displayActiveTorrents && torrent.uploadSpeed > widget.properties.speedLimitOfActiveTorrents * 1024));
|
||||
result = result.filter(
|
||||
(torrent) =>
|
||||
!torrent.isCompleted ||
|
||||
(widget.properties.displayActiveTorrents &&
|
||||
torrent.uploadSpeed > widget.properties.speedLimitOfActiveTorrents * 1024)
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.properties.labelFilter.length > 0) {
|
||||
@@ -279,4 +385,22 @@ export const getTorrentsRatio = (
|
||||
: -1;
|
||||
};
|
||||
|
||||
const formatSize = (sizeInBytes: number) => {
|
||||
return humanFileSize(sizeInBytes, false);
|
||||
};
|
||||
|
||||
const formatSpeed = (speedInBytesPerSecond: number) => {
|
||||
return `${humanFileSize(speedInBytesPerSecond, false)}/s`;
|
||||
};
|
||||
|
||||
const formatETA = (seconds: number) => {
|
||||
return calculateETA(seconds);
|
||||
};
|
||||
|
||||
const useStyles = createStyles(() => ({
|
||||
noTextBreak: {
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
}));
|
||||
|
||||
export default definition;
|
||||
|
||||
Reference in New Issue
Block a user