diff --git a/packages/backend/src/graphql/mutations/update-config.ee.ts b/packages/backend/src/graphql/mutations/update-config.ee.ts index 867897da..615d14c4 100644 --- a/packages/backend/src/graphql/mutations/update-config.ee.ts +++ b/packages/backend/src/graphql/mutations/update-config.ee.ts @@ -8,7 +8,11 @@ type Params = { }; }; -const updateConfig = async (_parent: unknown, params: Params, context: Context) => { +const updateConfig = async ( + _parent: unknown, + params: Params, + context: Context +) => { context.currentUser.can('update', 'Config'); const config = params.input; @@ -18,22 +22,26 @@ const updateConfig = async (_parent: unknown, params: Params, context: Context) for (const key of configKeys) { const newValue = config[key]; - const entryUpdate = Config - .query() - .insert({ - key, - value: { - data: newValue - } - }) - .onConflict('key') - .merge({ - value: { - data: newValue - } - }); + if (newValue) { + const entryUpdate = Config.query() + .insert({ + key, + value: { + data: newValue, + }, + }) + .onConflict('key') + .merge({ + value: { + data: newValue, + }, + }); - updates.push(entryUpdate); + updates.push(entryUpdate); + } else { + const entryUpdate = Config.query().findOne({ key }).delete(); + updates.push(entryUpdate); + } } await Promise.all(updates); diff --git a/packages/web/package.json b/packages/web/package.json index ea3aadd2..f71a46aa 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -30,6 +30,7 @@ "graphql": "^15.6.0", "lodash": "^4.17.21", "luxon": "^2.3.1", + "mui-color-input": "^2.0.0", "notistack": "^2.0.2", "react": "^17.0.2", "react-dom": "^17.0.2", diff --git a/packages/web/src/adminSettingsRoutes.tsx b/packages/web/src/adminSettingsRoutes.tsx index a0dd5452..3293aa4f 100644 --- a/packages/web/src/adminSettingsRoutes.tsx +++ b/packages/web/src/adminSettingsRoutes.tsx @@ -6,6 +6,7 @@ import CreateUser from 'pages/CreateUser'; import Roles from 'pages/Roles/index.ee'; import CreateRole from 'pages/CreateRole/index.ee'; import EditRole from 'pages/EditRole/index.ee'; +import UserInterface from 'pages/UserInterface'; import * as URLS from 'config/urls'; import Can from 'components/Can'; @@ -79,6 +80,17 @@ export default ( } /> + + + + + + } + /> + } diff --git a/packages/web/src/components/AdminSettingsLayout/index.tsx b/packages/web/src/components/AdminSettingsLayout/index.tsx index 69d0dfe4..62ffc84e 100644 --- a/packages/web/src/components/AdminSettingsLayout/index.tsx +++ b/packages/web/src/components/AdminSettingsLayout/index.tsx @@ -1,6 +1,7 @@ import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew'; import GroupIcon from '@mui/icons-material/Group'; import GroupsIcon from '@mui/icons-material/Groups'; +import BrushIcon from '@mui/icons-material/Brush'; import Box from '@mui/material/Box'; import Toolbar from '@mui/material/Toolbar'; import { useTheme } from '@mui/material/styles'; @@ -18,25 +19,43 @@ type SettingsLayoutProps = { }; type DrawerLink = { - Icon: SvgIconComponent, - primary: string, - to: string, -} + Icon: SvgIconComponent; + primary: string; + to: string; +}; -function createDrawerLinks({ canReadRole, canReadUser }: { canReadRole: boolean; canReadUser: boolean; }) { +function createDrawerLinks({ + canReadRole, + canReadUser, + canUpdateConfig, +}: { + canReadRole: boolean; + canReadUser: boolean; + canUpdateConfig: boolean; +}) { const items = [ - canReadUser ? { - Icon: GroupIcon, - primary: 'adminSettingsDrawer.users', - to: URLS.USERS, - } : null, - canReadRole ? { - Icon: GroupsIcon, - primary: 'adminSettingsDrawer.roles', - to: URLS.ROLES, - } : null - ] - .filter(Boolean) as DrawerLink[]; + canReadUser + ? { + Icon: GroupIcon, + primary: 'adminSettingsDrawer.users', + to: URLS.USERS, + } + : null, + canReadRole + ? { + Icon: GroupsIcon, + primary: 'adminSettingsDrawer.roles', + to: URLS.ROLES, + } + : null, + canUpdateConfig + ? { + Icon: BrushIcon, + primary: 'adminSettingsDrawer.userInterface', + to: URLS.USER_INTERFACE, + } + : null, + ].filter(Boolean) as DrawerLink[]; return items; } @@ -62,6 +81,7 @@ export default function SettingsLayout({ const drawerLinks = createDrawerLinks({ canReadUser: currentUserAbility.can('read', 'User'), canReadRole: currentUserAbility.can('read', 'Role'), + canUpdateConfig: currentUserAbility.can('update', 'Config'), }); return ( diff --git a/packages/web/src/components/ColorInput/index.tsx b/packages/web/src/components/ColorInput/index.tsx new file mode 100644 index 00000000..b705d414 --- /dev/null +++ b/packages/web/src/components/ColorInput/index.tsx @@ -0,0 +1,41 @@ +import * as React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; +import { MuiColorInput, MuiColorInputProps } from 'mui-color-input'; + +type ColorInputProps = { + shouldUnregister?: boolean; + name: string; + 'data-test'?: string; +} & Partial; + +export default function ColorInput(props: ColorInputProps): React.ReactElement { + const { control } = useFormContext(); + const { + required, + name, + shouldUnregister = false, + disabled = false, + 'data-test': dataTest, + ...textFieldProps + } = props; + + return ( + ( + + )} + /> + ); +} diff --git a/packages/web/src/config/urls.ts b/packages/web/src/config/urls.ts index f07ab3a1..729432da 100644 --- a/packages/web/src/config/urls.ts +++ b/packages/web/src/config/urls.ts @@ -3,12 +3,12 @@ import appConfig from './app'; export const CONNECTIONS = '/connections'; export const EXECUTIONS = '/executions'; export const EXECUTION_PATTERN = '/executions/:executionId'; -export const EXECUTION = (executionId: string) => - `/executions/${executionId}`; +export const EXECUTION = (executionId: string) => `/executions/${executionId}`; export const LOGIN = '/login'; export const LOGIN_CALLBACK = `${LOGIN}/callback`; -export const SSO_LOGIN = (issuer: string) => `${appConfig.apiUrl}/login/saml/${issuer}`; +export const SSO_LOGIN = (issuer: string) => + `${appConfig.apiUrl}/login/saml/${issuer}`; export const SIGNUP = '/sign-up'; export const FORGOT_PASSWORD = '/forgot-password'; export const RESET_PASSWORD = '/reset-password'; @@ -17,18 +17,19 @@ export const APPS = '/apps'; export const NEW_APP_CONNECTION = '/apps/new'; export const APP = (appKey: string) => `/app/${appKey}`; export const APP_PATTERN = '/app/:appKey'; -export const APP_CONNECTIONS = (appKey: string) => - `/app/${appKey}/connections`; +export const APP_CONNECTIONS = (appKey: string) => `/app/${appKey}/connections`; export const APP_CONNECTIONS_PATTERN = '/app/:appKey/connections'; export const APP_ADD_CONNECTION = (appKey: string, shared = false) => `/app/${appKey}/connections/add?shared=${shared}`; -export const APP_ADD_CONNECTION_WITH_AUTH_CLIENT_ID = (appKey: string, appAuthClientId: string) => - `/app/${appKey}/connections/add?appAuthClientId=${appAuthClientId}`; +export const APP_ADD_CONNECTION_WITH_AUTH_CLIENT_ID = ( + appKey: string, + appAuthClientId: string +) => `/app/${appKey}/connections/add?appAuthClientId=${appAuthClientId}`; export const APP_ADD_CONNECTION_PATTERN = '/app/:appKey/connections/add'; export const APP_RECONNECT_CONNECTION = ( appKey: string, connectionId: string, - appAuthClientId?: string, + appAuthClientId?: string ) => { const path = `/app/${appKey}/connections/${connectionId}/reconnect`; @@ -96,6 +97,7 @@ export const ROLES = `${ADMIN_SETTINGS}/roles`; export const ROLE = (roleId: string) => `${ROLES}/${roleId}`; export const ROLE_PATTERN = `${ROLES}/:roleId`; export const CREATE_ROLE = `${ROLES}/create`; +export const USER_INTERFACE = `${ADMIN_SETTINGS}/user-interface`; export const DASHBOARD = FLOWS; diff --git a/packages/web/src/helpers/nestObject.ts b/packages/web/src/helpers/nestObject.ts new file mode 100644 index 00000000..75c2e0b6 --- /dev/null +++ b/packages/web/src/helpers/nestObject.ts @@ -0,0 +1,18 @@ +import { IJSONObject } from '@automatisch/types'; +import set from 'lodash/set'; + +export default function nestObject( + config: IJSONObject | undefined +): Partial { + if (!config) return {}; + const result = {}; + + for (const key in config) { + if (Object.prototype.hasOwnProperty.call(config, key)) { + const value = config[key]; + set(result, key, value); + } + } + + return result; +} diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json index aac868cd..0e20261e 100644 --- a/packages/web/src/locales/en.json +++ b/packages/web/src/locales/en.json @@ -15,6 +15,7 @@ "settingsDrawer.billingAndUsage": "Billing and usage", "adminSettingsDrawer.users": "Users", "adminSettingsDrawer.roles": "Roles", + "adminSettingsDrawer.userInterface": "User Interface", "adminSettingsDrawer.goBack": "Go to the dashboard", "app.connectionCount": "{count} connections", "app.flowCount": "{count} flows", @@ -213,5 +214,12 @@ "permissionSettings.cancel": "Cancel", "permissionSettings.apply": "Apply", "permissionSettings.title": "Conditions", - "appAuthClientsDialog.title": "Choose your authentication client" + "appAuthClientsDialog.title": "Choose your authentication client", + "userInterfacePage.title": "User Interface", + "userInterfacePage.successfullyUpdated": "User interface has been updated.", + "userInterfacePage.mainColor": "Primary main color", + "userInterfacePage.darkColor": "Primary dark color", + "userInterfacePage.lightColor": "Primary light color", + "userInterfacePage.svgData": "Logo SVG code", + "userInterfacePage.submit": "Update" } diff --git a/packages/web/src/pages/UserInterface/index.tsx b/packages/web/src/pages/UserInterface/index.tsx new file mode 100644 index 00000000..59bf5304 --- /dev/null +++ b/packages/web/src/pages/UserInterface/index.tsx @@ -0,0 +1,130 @@ +import * as React from 'react'; +import { useMutation } from '@apollo/client'; +import Container from '@mui/material/Container'; +import Grid from '@mui/material/Grid'; +import Stack from '@mui/material/Stack'; +import LoadingButton from '@mui/lab/LoadingButton'; +import { useSnackbar } from 'notistack'; + +import { UPDATE_CONFIG } from 'graphql/mutations/update-config.ee'; +import useConfig from 'hooks/useConfig'; +import PageTitle from 'components/PageTitle'; +import Form from 'components/Form'; +import TextField from 'components/TextField'; +import useFormatMessage from 'hooks/useFormatMessage'; +import ColorInput from 'components/ColorInput'; +import nestObject from 'helpers/nestObject'; +import { Skeleton } from '@mui/material'; + +type UserInterface = { + palette: { + primary: { + dark: string; + light: string; + main: string; + }; + }; + logo: { + svgData: string; + }; +}; + +export default function UserInterface(): React.ReactElement { + const formatMessage = useFormatMessage(); + const [updateConfig, { loading }] = useMutation(UPDATE_CONFIG, { + refetchQueries: ['GetConfig'], + }); + const { config, loading: configLoading } = useConfig([ + 'palette.primary.main', + 'palette.primary.light', + 'palette.primary.dark', + 'logo.svgData', + ]); + const { enqueueSnackbar } = useSnackbar(); + + const handleUserInterfaceUpdate = async (uiData: Partial) => { + try { + await updateConfig({ + variables: { + input: { + 'palette.primary.main': uiData?.palette?.primary.main, + 'palette.primary.dark': uiData?.palette?.primary.dark, + 'palette.primary.light': uiData?.palette?.primary.light, + 'logo.svgData': uiData?.logo?.svgData, + }, + }, + }); + + enqueueSnackbar(formatMessage('userInterfacePage.successfullyUpdated'), { + variant: 'success', + }); + } catch (error) { + throw new Error('Failed while updating!'); + } + }; + + return ( + + + + {formatMessage('userInterfacePage.title')} + + + + {configLoading && ( + + + + + + + + )} + {!configLoading && ( +
(config)} + > + + + + + + + + + + + {formatMessage('userInterfacePage.submit')} + + +
+ )} +
+
+
+ ); +} diff --git a/yarn.lock b/yarn.lock index b92cfad6..dafb6b24 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1469,6 +1469,11 @@ resolved "https://registry.yarnpkg.com/@csstools/normalize.css/-/normalize.css-12.0.0.tgz#a9583a75c3f150667771f30b60d9f059473e62c4" integrity sha512-M0qqxAcwCsIVfpFQSlGN5XjXWu8l5JDZN+fPt1LeW5SZexQTgnaEvgXAY+CeygRw0EeppWHi12JxESWiWrB0Sg== +"@ctrl/tinycolor@^3.6.0": + version "3.6.0" + resolved "https://registry.yarnpkg.com/@ctrl/tinycolor/-/tinycolor-3.6.0.tgz#53fa5fe9c34faee89469e48f91d51a3766108bc8" + integrity sha512-/Z3l6pXthq0JvMYdUFyX9j0MaCltlIn6mfh9jLyQwg5aPKxkyNa0PTHtU1AlFXLNk55ZuAeJRcpvq+tmLfKmaQ== + "@dabh/diagnostics@^2.0.2": version "2.0.2" resolved "https://registry.yarnpkg.com/@dabh/diagnostics/-/diagnostics-2.0.2.tgz#290d08f7b381b8f94607dc8f471a12c675f9db31" @@ -12375,6 +12380,13 @@ msgpackr@^1.6.2: optionalDependencies: msgpackr-extract "^2.1.2" +mui-color-input@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/mui-color-input/-/mui-color-input-2.0.0.tgz#49c8df63d3d18f1a1817572c0efc15bd970b35a2" + integrity sha512-Xw6OGsZVbtlZEAUVgJ08Lyv4u0YDQH+aTMJhhWm2fRin+1T+0IrVFyBtbSjJjrH4aBkkQPMCm75//7qO9zncLw== + dependencies: + "@ctrl/tinycolor" "^3.6.0" + multer@1.4.5-lts.1: version "1.4.5-lts.1" resolved "https://registry.yarnpkg.com/multer/-/multer-1.4.5-lts.1.tgz#803e24ad1984f58edffbc79f56e305aec5cfd1ac"