Merge branch 'dev' into docker-onboarding-fix

This commit is contained in:
Thomas Camlong
2023-12-31 11:42:56 +01:00
committed by GitHub
298 changed files with 7179 additions and 3511 deletions

View File

@@ -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[]>;

View File

@@ -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}

View File

@@ -0,0 +1,81 @@
import { Box, Button, Group, TextInput, Title } from '@mantine/core';
import { useForm, zodResolver } from '@mantine/form';
import { IconAt, IconCheck, IconLetterCase } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { z } from 'zod';
import { api } from '~/utils/api';
export const ManageUserGeneralForm = ({
userId,
defaultUsername,
defaultEmail,
}: {
userId: string;
defaultUsername: string;
defaultEmail: string;
}) => {
const form = useForm({
initialValues: {
username: defaultUsername,
eMail: defaultEmail,
},
validate: zodResolver(
z.object({
username: z.string(),
eMail: z.string().email().or(z.literal('')),
})
),
validateInputOnBlur: true,
validateInputOnChange: true,
});
const { t } = useTranslation(['manage/users/edit', 'common']);
const utils = api.useUtils();
const { mutate, isLoading } = api.user.updateDetails.useMutation({
onSettled: async () => {
await utils.user.invalidate();
form.resetDirty();
},
});
function handleSubmit() {
mutate({
userId: userId,
username: form.values.username,
eMail: form.values.eMail,
});
}
return (
<Box maw={500}>
<Title order={3}>{t('sections.general.title')}</Title>
<form onSubmit={form.onSubmit(handleSubmit)}>
<TextInput
icon={<IconLetterCase size="1rem" />}
label={t('sections.general.inputs.username.label')}
mb="md"
withAsterisk
{...form.getInputProps('username')}
/>
<TextInput
icon={<IconAt size="1rem" />}
label={t('sections.general.inputs.eMail.label')}
{...form.getInputProps('eMail')}
/>
<Group position="right" mt="md">
<Button
disabled={!form.isDirty() || !form.isValid() || isLoading}
loading={isLoading}
leftIcon={<IconCheck size="1rem" />}
color="green"
variant="light"
type="submit"
>
{t('common:save')}
</Button>
</Group>
</form>
</Box>
);
};

View File

@@ -0,0 +1,83 @@
import { Box, Button, Checkbox, Group, LoadingOverlay, PasswordInput, Title } from '@mantine/core';
import { useForm, zodResolver } from '@mantine/form';
import { IconTextSize, IconTrash } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { z } from 'zod';
import { api } from '~/utils/api';
export const ManageUserDanger = ({
userId,
username,
}: {
userId: string;
username: string | null;
}) => {
const form = useForm({
initialValues: {
username: '',
confirm: false,
},
validate: zodResolver(
z.object({
username: z.literal(username),
confirm: z.literal(true),
})
),
validateInputOnBlur: true,
validateInputOnChange: true,
});
const apiUtils = api.useUtils();
const { mutate, isLoading } = api.user.deleteUser.useMutation({
onSuccess: () => {
window.location.href = '/manage/users';
},
onSettled: () => {
void apiUtils.user.details.invalidate();
form.reset();
},
});
const { t } = useTranslation(['manage/users/edit', 'common']);
const handleSubmit = () => {
mutate({
id: userId,
});
};
return (
<Box maw={500}>
<LoadingOverlay visible={isLoading} />
<Title order={3}>{t('sections.deletion.title')}</Title>
<form onSubmit={form.onSubmit(handleSubmit)}>
<PasswordInput
icon={<IconTextSize size="1rem" />}
label={t('sections.deletion.inputs.confirmUsername.label')}
description={t('sections.deletion.inputs.confirmUsername.description')}
mb="md"
withAsterisk
{...form.getInputProps('username')}
/>
<Checkbox
label={t('sections.deletion.inputs.confirm.label')}
description={t('sections.deletion.inputs.confirm.description')}
{...form.getInputProps('confirm')}
/>
<Group position="right" mt="md">
<Button
disabled={!form.isDirty() || !form.isValid()}
leftIcon={<IconTrash size="1rem" />}
loading={isLoading}
color="red"
variant="light"
type="submit"
>
{t('common:delete')}
</Button>
</Group>
</form>
</Box>
);
};

View File

@@ -0,0 +1,65 @@
import { ActionIcon, Badge, Box, Group, Title, Text, Tooltip, Button } from '@mantine/core';
import { openRoleChangeModal } from '~/components/Manage/User/change-user-role.modal';
import { IconUserDown, IconUserUp } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { useSession } from 'next-auth/react';
export const ManageUserRoles = ({ user }: {
user: {
image: string | null;
id: string;
name: string | null;
password: string | null;
email: string | null;
emailVerified: Date | null;
salt: string | null;
isAdmin: boolean;
isOwner: boolean;
}
}) => {
const { t } = useTranslation(['manage/users/edit', 'manage/users']);
const { data: sessionData } = useSession();
return (
<Box maw={500}>
<Title order={3}>
{t('sections.roles.title')}
</Title>
<Group mb={'md'}>
<Text>{t('sections.roles.currentRole')}</Text>
{user.isOwner ? (<Badge>{t('sections.roles.badges.owner')}</Badge>) : user.isAdmin ? (
<Badge>{t('sections.roles.badges.admin')}</Badge>) : (<Badge>{t('sections.roles.badges.normal')}</Badge>)}
</Group>
{user.isAdmin ? (
<Button
leftIcon={<IconUserDown size='1rem' />}
disabled={user.id === sessionData?.user?.id || user.isOwner}
onClick={() => {
openRoleChangeModal({
name: user.name as string,
id: user.id,
type: 'demote',
});
}}
>
{t('manage/users:tooltips.demoteAdmin')}
</Button>
) : (
<Button
leftIcon={<IconUserUp size='1rem' />}
onClick={() => {
openRoleChangeModal({
name: user.name as string,
id: user.id,
type: 'promote',
});
}}
>
{t('manage/users:tooltips.promoteToAdmin')}
</Button>
)}
</Box>
);
};

View File

@@ -0,0 +1,91 @@
import { Box, Button, Checkbox, Group, LoadingOverlay, PasswordInput, Title } from '@mantine/core';
import { useForm, zodResolver } from '@mantine/form';
import { useInputState } from '@mantine/hooks';
import { IconAlertTriangle, IconPassword } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { z } from 'zod';
import { api } from '~/utils/api';
export const ManageUserSecurityForm = ({ userId }: { userId: string }) => {
const form = useForm({
initialValues: {
password: '',
terminateExistingSessions: false,
confirm: false,
},
validate: zodResolver(
z.object({
password: z.string().min(3),
terminateExistingSessions: z.boolean(),
confirm: z.literal(true),
})
),
validateInputOnBlur: true,
validateInputOnChange: true,
});
const [checked, setChecked] = useInputState(false);
const { t } = useTranslation(['manage/users/edit', 'common']);
const apiUtils = api.useUtils();
const { mutate, isLoading } = api.user.updatePassword.useMutation({
onSettled: () => {
void apiUtils.user.details.invalidate();
form.reset();
},
});
const handleSubmit = (values: { password: string; terminateExistingSessions: boolean }) => {
mutate({
newPassword: values.password,
terminateExistingSessions: values.terminateExistingSessions,
userId: userId,
});
setChecked(false);
};
return (
<Box maw={500}>
<LoadingOverlay visible={isLoading} />
<Title order={3}>{t('sections.security.title')}</Title>
<form onSubmit={form.onSubmit(handleSubmit)}>
<PasswordInput
icon={<IconPassword size="1rem" />}
label={t('sections.security.inputs.password.label')}
mb="md"
withAsterisk
{...form.getInputProps('password')}
/>
<Checkbox
label={t('sections.security.inputs.terminateExistingSessions.label')}
description={t('sections.security.inputs.terminateExistingSessions.description')}
mb="md"
{...form.getInputProps('terminateExistingSessions')}
/>
<Checkbox
label={t('sections.security.inputs.confirm.label')}
description={t('sections.security.inputs.confirm.description')}
checked={checked}
onClick={(event) => {
setChecked(event.currentTarget.checked);
}}
{...form.getInputProps('confirm')}
/>
<Group position="right" mt="md">
<Button
disabled={!form.isDirty() || !form.isValid()}
leftIcon={<IconAlertTriangle size="1rem" />}
loading={isLoading}
color="red"
variant="light"
type="submit"
>
{t('common:save')}
</Button>
</Group>
</form>
</Box>
);
};

View File

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

View File

@@ -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

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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">

View File

@@ -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,

View File

@@ -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
)),
},
};
}

View File

@@ -0,0 +1,90 @@
import { Avatar, Divider, Group, Loader, Stack, Text, ThemeIcon, Title, UnstyledButton } from '@mantine/core';
import { IconArrowLeft } from '@tabler/icons-react';
import { GetServerSideProps } from 'next';
import { useTranslation } from 'next-i18next';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { ManageUserGeneralForm } from '~/components/Manage/User/Edit/GeneralForm';
import { ManageUserDanger } from '~/components/Manage/User/Edit/ManageUserDanger';
import { ManageUserSecurityForm } from '~/components/Manage/User/Edit/ManageUserSecurityForm';
import { ManageLayout } from '~/components/layout/Templates/ManageLayout';
import { getServerAuthSession } from '~/server/auth';
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
import { manageNamespaces } from '~/tools/server/translation-namespaces';
import { api } from '~/utils/api';
import { ManageUserRoles } from '~/components/Manage/User/Edit/ManageUserRoles';
const EditPage = () => {
const { t } = useTranslation('manage/users/edit');
const router = useRouter();
const { isLoading, data } = api.user.details.useQuery({ userId: router.query.userId as string });
const metaTitle = `${t('metaTitle', {
username: data?.name,
})} • Homarr`;
return (
<ManageLayout>
<Head>
<title>{metaTitle}</title>
</Head>
<UnstyledButton component={Link} href='/manage/users'>
<Group mb='md'>
<ThemeIcon variant='default'>
<IconArrowLeft size='1rem' />
</ThemeIcon>
<Text>{t('back')}</Text>
</Group>
</UnstyledButton>
<Group mb='xl'>
<Avatar>{data?.name?.slice(0, 2).toUpperCase()}</Avatar>
<Title>{data?.name}</Title>
</Group>
{data ? (
<Stack>
<ManageUserGeneralForm
defaultUsername={data?.name ?? ''}
defaultEmail={data?.email ?? ''}
userId={data.id}
/>
<Divider />
<ManageUserSecurityForm userId={data.id} />
<Divider />
<ManageUserRoles user={data} />
<Divider />
<ManageUserDanger userId={data.id} username={data.name} />
</Stack>
) : (
<Loader />
)}
</ManageLayout>
);
};
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const session = await getServerAuthSession(ctx);
const result = checkForSessionOrAskForLogin(ctx, session, () => session?.user.isAdmin == true);
if (result) {
return result;
}
const translations = await getServerSideTranslations(
manageNamespaces,
ctx.locale,
undefined,
undefined,
);
return {
props: {
...translations,
},
};
};
export default EditPage;

View File

@@ -1,28 +1,34 @@
import {
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,

View File

@@ -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

View File

@@ -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);
}
}),
});

View File

@@ -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) => {

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

View File

@@ -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;
}),
});

View File

@@ -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),

View File

@@ -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' },

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

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

View File

@@ -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'];

View File

@@ -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;
}

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

View File

@@ -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 = {

View File

@@ -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>

View File

@@ -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
};

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

View File

@@ -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();

View File

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