diff --git a/public/locales/en/manage/integrations.json b/public/locales/en/manage/integrations.json new file mode 100644 index 000000000..519b13bd5 --- /dev/null +++ b/public/locales/en/manage/integrations.json @@ -0,0 +1,24 @@ +{ + "title": "Integrations settings", + "metaTitle": "Integrations", + "pageTitle": "Manage integration", + "text": "Here you can configure all the integrations you want to use within Honmarr.", + "addNewIntegration": "Add new integration", + "savedSuccessfully": "Your changes have been saved successfully, you can now close this window", + "deleteConfirmation": "Are you sure you want to delete {{name}} ?", + "closeConfirmation": "Are you sure you'd like to close this window ?", + "CloseConfirmationExplanation": "If you close this window, your changes will not be saved", + "integration": { + "urlDescription": "Url for the integration, generally an IP", + "name": "Name", + "nameDescription": "The name of the integration", + "Added": "Integration added!", + "AddedDescription": "The integration {{name}} has been added successfully" + }, + "fields": { + "username": "Username", + "apikey": "Api key", + "password": "Password", + "unknown": "Unknown" + } +} \ No newline at end of file diff --git a/src/components/Config/Integration/AddIntegrationPanel.tsx b/src/components/Config/Integration/AddIntegrationPanel.tsx new file mode 100644 index 000000000..b23ef18a4 --- /dev/null +++ b/src/components/Config/Integration/AddIntegrationPanel.tsx @@ -0,0 +1,124 @@ +import { Button, Group, Stack, TextInput, useMantineTheme } from '@mantine/core'; +import { UseFormReturnType, useForm } from '@mantine/form'; +import { notifications } from '@mantine/notifications'; +import { QueryKey, useQueryClient } from '@tanstack/react-query'; +import { getQueryKey } from '@trpc/react-query'; +import { getCookie } from 'cookies-next'; +import { produce } from 'immer'; +import { useTranslation } from 'react-i18next'; +import { v4 as uuidv4 } from 'uuid'; +import { IntegrationTab } from '~/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/IntegrationTab'; +import { AppType } from '~/types/app'; +import { IntegrationTypeMap } from '~/types/config'; +import { api } from '~/utils/api'; + +const defaultAppValues: AppType = { + id: uuidv4(), + name: 'Your app', + url: 'https://homarr.dev', + appearance: { + iconUrl: '/imgs/logo/logo.png', + appNameStatus: 'normal', + positionAppName: '-moz-initial', + lineClampAppName: 2 + }, + network: { + enabledStatusChecker: true, + statusCodes: ['200', '301', '302', '304', '307', '308'], + okStatus: [200, 301, 302, 304, 307, 308], + }, + behaviour: { + isOpeningNewTab: true, + externalUrl: '', + }, + + area: { + type: 'wrapper', + properties: { + id: 'default', + }, + }, + shape: {}, + integration: { + id: uuidv4(), + url: '', + type: undefined, + properties: [], + name: 'New integration', + }, +}; + +export function AddIntegrationPanel({ + globalForm, + queryKey, + integrations, + setIntegrations, +}: { + globalForm: UseFormReturnType; + queryKey: QueryKey; + integrations: IntegrationTypeMap | undefined; + setIntegrations: React.Dispatch>; +}) { + const { t } = useTranslation(['board/integrations', 'common']); + const queryClient = useQueryClient(); + const { primaryColor } = useMantineTheme(); + + const form = useForm({ + initialValues: defaultAppValues, + }); + + if (!integrations) { + return null; + } + + return ( +
{ + if (!integration.type || !integrations) return null; + const newIntegrations = produce(integrations, (draft) => { + integration.id = uuidv4(); + // console.log(integration.type); + if (!integration.type) return; + // If integration type is not in integrations, add it + if (!draft[integration.type]) { + draft[integration.type] = []; + } + draft[integration.type].push(integration); + }); + // queryClient.setQueryData(queryKey, newIntegrations); + form.reset(); + setIntegrations(newIntegrations); + notifications.show({ + title: t('integration.Added'), + message: t('integration.AddedDescription', { name: integration.name }), + color: 'green', + }); + })} + > + + + + + + + + +
+ ); +} diff --git a/src/pages/manage/integrations/AddIntegrationPanel.tsx b/src/pages/manage/integrations/AddIntegrationPanel.tsx new file mode 100644 index 000000000..b23ef18a4 --- /dev/null +++ b/src/pages/manage/integrations/AddIntegrationPanel.tsx @@ -0,0 +1,124 @@ +import { Button, Group, Stack, TextInput, useMantineTheme } from '@mantine/core'; +import { UseFormReturnType, useForm } from '@mantine/form'; +import { notifications } from '@mantine/notifications'; +import { QueryKey, useQueryClient } from '@tanstack/react-query'; +import { getQueryKey } from '@trpc/react-query'; +import { getCookie } from 'cookies-next'; +import { produce } from 'immer'; +import { useTranslation } from 'react-i18next'; +import { v4 as uuidv4 } from 'uuid'; +import { IntegrationTab } from '~/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/IntegrationTab'; +import { AppType } from '~/types/app'; +import { IntegrationTypeMap } from '~/types/config'; +import { api } from '~/utils/api'; + +const defaultAppValues: AppType = { + id: uuidv4(), + name: 'Your app', + url: 'https://homarr.dev', + appearance: { + iconUrl: '/imgs/logo/logo.png', + appNameStatus: 'normal', + positionAppName: '-moz-initial', + lineClampAppName: 2 + }, + network: { + enabledStatusChecker: true, + statusCodes: ['200', '301', '302', '304', '307', '308'], + okStatus: [200, 301, 302, 304, 307, 308], + }, + behaviour: { + isOpeningNewTab: true, + externalUrl: '', + }, + + area: { + type: 'wrapper', + properties: { + id: 'default', + }, + }, + shape: {}, + integration: { + id: uuidv4(), + url: '', + type: undefined, + properties: [], + name: 'New integration', + }, +}; + +export function AddIntegrationPanel({ + globalForm, + queryKey, + integrations, + setIntegrations, +}: { + globalForm: UseFormReturnType; + queryKey: QueryKey; + integrations: IntegrationTypeMap | undefined; + setIntegrations: React.Dispatch>; +}) { + const { t } = useTranslation(['board/integrations', 'common']); + const queryClient = useQueryClient(); + const { primaryColor } = useMantineTheme(); + + const form = useForm({ + initialValues: defaultAppValues, + }); + + if (!integrations) { + return null; + } + + return ( +
{ + if (!integration.type || !integrations) return null; + const newIntegrations = produce(integrations, (draft) => { + integration.id = uuidv4(); + // console.log(integration.type); + if (!integration.type) return; + // If integration type is not in integrations, add it + if (!draft[integration.type]) { + draft[integration.type] = []; + } + draft[integration.type].push(integration); + }); + // queryClient.setQueryData(queryKey, newIntegrations); + form.reset(); + setIntegrations(newIntegrations); + notifications.show({ + title: t('integration.Added'), + message: t('integration.AddedDescription', { name: integration.name }), + color: 'green', + }); + })} + > + + + + + + + + +
+ ); +} diff --git a/src/pages/manage/integrations/IntegrationModal.tsx b/src/pages/manage/integrations/IntegrationModal.tsx new file mode 100644 index 000000000..8e2a22e2e --- /dev/null +++ b/src/pages/manage/integrations/IntegrationModal.tsx @@ -0,0 +1,530 @@ +import { + Accordion, + ActionIcon, + Button, + Group, + Image, + Loader, + Menu, + Modal, + PasswordInput, + Popover, + Stack, + Text, + TextInput, + ThemeIcon, + Title, + Tooltip, + rem, + useMantineTheme, +} from '@mantine/core'; +import { UseFormReturnType, useForm } from '@mantine/form'; +import { modals } from '@mantine/modals'; +import { notifications } from '@mantine/notifications'; +import { + IconCheck, + IconCircleX, + IconDeviceFloppy, + IconExternalLink, + IconKey, + IconLock, + IconPassword, + IconPlug, + IconPlugConnected, + IconPlus, + IconQuestionMark, + IconTestPipe, + IconTrash, + IconUser, + IconX, +} from '@tabler/icons-react'; +import { useQueryClient } from '@tanstack/react-query'; +import { getQueryKey } from '@trpc/react-query'; +import { getCookie, setCookie } from 'cookies-next'; +import { produce } from 'immer'; +import Link from 'next/link'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { integrationsList } from '~/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector'; +import { useConfigContext } from '~/config/provider'; +import { Integration } from '~/types/app'; +import { IntegrationTypeMap } from '~/types/config'; +import { api } from '~/utils/api'; + +import { AddIntegrationPanel } from './AddIntegrationPanel'; + +const ModalTitle = ({ title, description }: { title: string; description: string }) => ( +
+ + {title} + + {description} +
+); + +export function IntegrationMenu({ integrationsModal }: { integrationsModal: any }) { + const { t } = useTranslation('common'); + const cookie = getCookie('INTEGRATIONS_PASSWORD'); + const form = useForm({ + initialValues: { + password: '', + }, + }); + const checkLogin = api.system.checkLogin.useQuery( + { password: cookie?.toString() }, + { enabled: !!cookie, retry: false } + ); + const mutation = api.system.tryPassword.useMutation({ + onError(error, variables, context) { + notifications.show({ + title: 'There was an error', + message: error.message, + color: 'red', + }); + }, + }); + if (mutation.isLoading) + return }>{t('sections.integrations')}; + return ( + } + {...(checkLogin.isSuccess && { onClick: integrationsModal.open })} + > + + {t('sections.integrations')} + {!checkLogin.isSuccess && ( +
{ + mutation.mutate({ password }); + setCookie('INTEGRATIONS_PASSWORD', password); + checkLogin.refetch(); + })} + > + } + visibilityToggleLabel={undefined} + {...form.getInputProps('password')} + /> + + )} +
+
+ ); +} + +function IntegrationDisplay({ + integration, + integrations, + setIntegrations, + integrationIdx, + form, +}: { + integration: Integration; + integrations: IntegrationTypeMap; + setIntegrations: (integrations: IntegrationTypeMap) => void; + integrationIdx: number; + form: UseFormReturnType; +}) { + if (!integration.type) return null; + const { t } = useTranslation(['settings/integrations', 'common']); + const mutation = api.system.testIntegration.useMutation(); + + return ( + + {integration.name} + + + + + + + + + } + {...form.getInputProps(`${integration.type}.${integrationIdx}.url`)} + /> + + + + {integration.properties.map((property, idx) => { + if (!property.value) return null; + if (property.type === 'private') + return ( + : } + defaultValue={property.value} + key={property.field} + label={property.field} + {...form.getInputProps( + `${integration.type}.${integrationIdx}.properties.${idx}.value` + )} + /> + ); + else if (property.type === 'public') + return ( + : } + defaultValue={property.value} + key={property.field} + label={property.field} + {...form.getInputProps( + `${integration.type}.${integrationIdx}.properties.${idx}.value` + )} + /> + ); + })} + + + + + + + + + {t('common:delete')} + {t('deleteConfirmation', { name: integration.name })} + + + + + + + + + + + + ); +} + +export function SecretsInputs({ + integration, + integrationIdx, + form, +}: { + integration: Integration; + integrationIdx: number; + form: UseFormReturnType; +}) { + const { t } = useTranslation('settings/integrations'); + return ( + + {integration.properties.map((property, idx) => { + if (!property.value) return null; + + switch (property.field) { + case 'apiKey': + return ( + + + + + + + + + ); + case 'username': + return ( + + + + + + + + + ); + case 'password': + return ( + + + + + + + + + ); + // Other case + default: + return ( + + + + + + + + + ); + } + })} + + ); +} + +// export type IntegrationType = +// | 'readarr' +// | 'radarr' +// | 'sonarr' +// | 'lidarr' +// | 'sabnzbd' +// | 'jellyseerr' +// | 'overseerr' +// | 'deluge' +// | 'qBittorrent' +// | 'transmission' +// | 'plex' +// | 'jellyfin' +// | 'nzbGet' +// | 'pihole' +// | 'adGuardHome'; + +export interface IntegrationObject { + [key: string]: Integration; +} + +export function IntegrationsAccordion({ closeModal }: { closeModal: () => void }) { + const { t } = useTranslation('settings/integrations, common'); + const cookie = getCookie('INTEGRATIONS_PASSWORD'); + const queryClient = useQueryClient(); + const { primaryColor } = useMantineTheme(); + const integrationsQuery: IntegrationTypeMap | undefined = queryClient.getQueryData(queryKey); + const mutation = api.config.save.useMutation(); + const { config, name } = useConfigContext(); + const [isLoading, setIsLoading] = useState(false); + const [integrations, setIntegrations] = useState( + integrationsQuery + ); + if (!integrations) { + return null; + } + + let form = useForm({ + initialValues: integrationsQuery, + }); + + return ( + + + {Object.keys(integrations).map((item) => { + if (!integrations) return null; + const configIntegrations = integrations[item as keyof IntegrationTypeMap]; + const integrationListItem = integrationsList.find( + (integration) => integration.value === item + ); + if (!configIntegrations || !integrationListItem) return null; + return ( + + + } + > + {integrationListItem.label} + + + + {configIntegrations.map((integration, integrationIdx) => { + return ( + + ); + })} + + + + ); + })} + + + + + } + icon={} + > + {t('settings/integrations:addNewIntegration')} + + + + + + + + + + + + ); +} + +export function IntegrationModal({ + opened, + closeModal, +}: { + opened: boolean; + closeModal: () => void; +}) { + const { t } = useTranslation('settings/integrations'); + return ( + } + opened={opened} + closeOnClickOutside={false} + onClose={() => + modals.openConfirmModal({ + withCloseButton: false, + title: t('CloseConfirmation'), + children: {t('CloseConfirmationExplanation')}, + labels: { confirm: 'Close it anyways', cancel: 'Cancel' }, + onConfirm: closeModal, + }) + } + fullScreen + > + + + ); +} diff --git a/src/pages/manage/integrations/index.tsx b/src/pages/manage/integrations/index.tsx new file mode 100644 index 000000000..5f877da7e --- /dev/null +++ b/src/pages/manage/integrations/index.tsx @@ -0,0 +1,105 @@ +import { + ActionIcon, + Autocomplete, + Avatar, + Badge, + Box, + Button, + Flex, + Group, + Pagination, + Table, + Text, + Title, + Tooltip, + useMantineTheme, +} from '@mantine/core'; +import { useDebouncedValue } from '@mantine/hooks'; +import { IconPlus, IconTrash, IconUserDown, IconUserUp } 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 { ManageLayout } from '~/components/layout/Templates/ManageLayout'; +import { getServerAuthSession } from '~/server/auth'; +import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; +import { manageNamespaces } from '~/tools/server/translation-namespaces'; +import { api } from '~/utils/api'; + +import { AddIntegrationPanel } from './AddIntegrationPanel'; +import { useQueryClient } from '@tanstack/react-query'; +import { IntegrationTypeMap } from '~/types/config'; +import { useConfigContext } from '~/config/provider'; +import { useForm } from '@mantine/form'; + +const ManageUsersPage = () => { + const [activePage, setActivePage] = useState(0); + const [nonDebouncedSearch, setNonDebouncedSearch] = useState(''); + const [debouncedSearch] = useDebouncedValue(nonDebouncedSearch, 200); + const { t } = useTranslation('manage/integrations'); + const queryClient = useQueryClient(); + const { primaryColor } = useMantineTheme(); + const integrationsQuery: IntegrationTypeMap | undefined = queryClient.getQueryData(queryKey); + const mutation = api.config.save.useMutation(); + const { config, name } = useConfigContext(); + const [isLoading, setIsLoading] = useState(false); + const { data: sessionData } = useSession(); + const [integrations, setIntegrations] = useState( + integrationsQuery + ); + + if (!integrations) { + return null; + } + + const form = useForm({ + initialValues: integrationsQuery, + }); + + const metaTitle = `${t('metaTitle')} • Homarr`; + + return ( + + + {metaTitle} + + + {t('pageTitle')} + {t('text')} + + + ); +}; + +export const getServerSideProps: GetServerSideProps = async (ctx) => { + const session = await getServerAuthSession(ctx); + + if (!session?.user.isAdmin) { + return { + notFound: true, + }; + } + + const translations = await getServerSideTranslations( + manageNamespaces, + ctx.locale, + undefined, + undefined + ); + return { + props: { + ...translations, + }, + }; +}; + +export default ManageUsersPage;