From bafb8b86db105435da7491eaa27ed3d6e339937e Mon Sep 17 00:00:00 2001 From: Ali BARIN Date: Thu, 10 Aug 2023 14:31:26 +0000 Subject: [PATCH 1/6] feat: create Config model --- .../20230810124730_create_config.ts | 15 ++++++++++ ...seed_update_config_permissions_to_admin.ts | 30 +++++++++++++++++++ packages/backend/src/models/config.ts | 23 ++++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 packages/backend/src/db/migrations/20230810124730_create_config.ts create mode 100644 packages/backend/src/db/migrations/20230810134714_seed_update_config_permissions_to_admin.ts create mode 100644 packages/backend/src/models/config.ts diff --git a/packages/backend/src/db/migrations/20230810124730_create_config.ts b/packages/backend/src/db/migrations/20230810124730_create_config.ts new file mode 100644 index 0000000000..76bc914e00 --- /dev/null +++ b/packages/backend/src/db/migrations/20230810124730_create_config.ts @@ -0,0 +1,15 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + return knex.schema.createTable('config', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.string('key').unique().notNullable(); + table.jsonb('value').notNullable().defaultTo({}); + + table.timestamps(true, true); + }); +} + +export async function down(knex: Knex): Promise { + return knex.schema.dropTable('config'); +} diff --git a/packages/backend/src/db/migrations/20230810134714_seed_update_config_permissions_to_admin.ts b/packages/backend/src/db/migrations/20230810134714_seed_update_config_permissions_to_admin.ts new file mode 100644 index 0000000000..e953a08546 --- /dev/null +++ b/packages/backend/src/db/migrations/20230810134714_seed_update_config_permissions_to_admin.ts @@ -0,0 +1,30 @@ +import { Knex } from 'knex'; + +const getPermissionForRole = ( + roleId: string, + subject: string, + actions: string[] +) => + actions.map((action) => ({ + role_id: roleId, + subject, + action, + conditions: [], + })); + +export async function up(knex: Knex): Promise { + const role = (await knex('roles') + .first(['id', 'key']) + .where({ key: 'admin' }) + .limit(1)) as { id: string; key: string }; + + await knex('permissions').insert( + getPermissionForRole(role.id, 'Config', [ + 'update', + ]) + ); +} + +export async function down(knex: Knex): Promise { + await knex('permissions').where({ subject: 'Config' }).delete(); +} diff --git a/packages/backend/src/models/config.ts b/packages/backend/src/models/config.ts new file mode 100644 index 0000000000..a42b8f9dc3 --- /dev/null +++ b/packages/backend/src/models/config.ts @@ -0,0 +1,23 @@ +import { IJSONValue } from '@automatisch/types'; +import Base from './base'; + +class Config extends Base { + id!: string; + key!: string; + value!: { data: IJSONValue }; + + static tableName = 'config'; + + static jsonSchema = { + type: 'object', + required: ['key', 'value'], + + properties: { + id: { type: 'string', format: 'uuid' }, + key: { type: 'string', minLength: 1 }, + value: { type: 'object' }, + }, + }; +} + +export default Config; From efd243a340a37c54723d529546b5d8ad3be7dcde Mon Sep 17 00:00:00 2001 From: Ali BARIN Date: Thu, 10 Aug 2023 14:32:11 +0000 Subject: [PATCH 2/6] feat: create getConfig GQL query --- .../backend/src/graphql/queries/get-config.ts | 31 +++++++++++++++++++ .../backend/src/graphql/query-resolvers.ts | 10 +++--- packages/backend/src/graphql/schema.graphql | 23 +++++++------- .../backend/src/helpers/authentication.ts | 1 + 4 files changed, 50 insertions(+), 15 deletions(-) create mode 100644 packages/backend/src/graphql/queries/get-config.ts diff --git a/packages/backend/src/graphql/queries/get-config.ts b/packages/backend/src/graphql/queries/get-config.ts new file mode 100644 index 0000000000..72aa90d8ce --- /dev/null +++ b/packages/backend/src/graphql/queries/get-config.ts @@ -0,0 +1,31 @@ +import Context from '../../types/express/context'; +import Config from '../../models/config'; + +type Params = { + keys: string[]; +}; + +const getConfig = async ( + _parent: unknown, + params: Params, + context: Context +) => { + const configQuery = Config + .query(); + + if (Array.isArray(params.keys)) { + configQuery.whereIn('key', params.keys); + } + + const config = await configQuery; + + return config.reduce((computedConfig, configEntry) => { + const { key, value } = configEntry; + + computedConfig[key] = value?.data; + + return computedConfig; + }, {} as Record); +}; + +export default getConfig; diff --git a/packages/backend/src/graphql/query-resolvers.ts b/packages/backend/src/graphql/query-resolvers.ts index 41dbcc9879..932a1d1823 100644 --- a/packages/backend/src/graphql/query-resolvers.ts +++ b/packages/backend/src/graphql/query-resolvers.ts @@ -3,6 +3,7 @@ import getApps from './queries/get-apps'; import getAutomatischInfo from './queries/get-automatisch-info'; import getBillingAndUsage from './queries/get-billing-and-usage.ee'; import getConnectedApps from './queries/get-connected-apps'; +import getConfig from './queries/get-config'; import getCurrentUser from './queries/get-current-user'; import getDynamicData from './queries/get-dynamic-data'; import getDynamicFields from './queries/get-dynamic-fields'; @@ -11,20 +12,20 @@ import getExecutionSteps from './queries/get-execution-steps'; import getExecutions from './queries/get-executions'; import getFlow from './queries/get-flow'; import getFlows from './queries/get-flows'; -import getUser from './queries/get-user'; -import getUsers from './queries/get-users'; import getInvoices from './queries/get-invoices.ee'; import getPaddleInfo from './queries/get-paddle-info.ee'; import getPaymentPlans from './queries/get-payment-plans.ee'; import getPermissionCatalog from './queries/get-permission-catalog.ee'; import getRole from './queries/get-role.ee'; import getRoles from './queries/get-roles.ee'; -import listSamlAuthProviders from './queries/list-saml-auth-providers.ee'; import getSamlAuthProvider from './queries/get-saml-auth-provider.ee'; import getStepWithTestExecutions from './queries/get-step-with-test-executions'; import getSubscriptionStatus from './queries/get-subscription-status.ee'; import getTrialStatus from './queries/get-trial-status.ee'; +import getUser from './queries/get-user'; +import getUsers from './queries/get-users'; import healthcheck from './queries/healthcheck'; +import listSamlAuthProviders from './queries/list-saml-auth-providers.ee'; import testConnection from './queries/test-connection'; const queryResolvers = { @@ -32,6 +33,7 @@ const queryResolvers = { getApps, getAutomatischInfo, getBillingAndUsage, + getConfig, getConnectedApps, getCurrentUser, getDynamicData, @@ -47,7 +49,6 @@ const queryResolvers = { getPermissionCatalog, getRole, getRoles, - listSamlAuthProviders, getSamlAuthProvider, getStepWithTestExecutions, getSubscriptionStatus, @@ -55,6 +56,7 @@ const queryResolvers = { getUser, getUsers, healthcheck, + listSamlAuthProviders, testConnection, }; diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index 9eeadbbefe..a2b189a36f 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -33,22 +33,23 @@ type Query { key: String! parameters: JSONObject ): [SubstepArgument] - getCurrentUser: User - getPaymentPlans: [PaymentPlan] - getPaddleInfo: GetPaddleInfo + getAutomatischInfo: GetAutomatischInfo getBillingAndUsage: GetBillingAndUsage + getCurrentUser: User + getConfig(keys: [String]): JSONObject getInvoices: [Invoice] - getAutomatischInfo: GetAutomatischInfo - getTrialStatus: GetTrialStatus - getSubscriptionStatus: GetSubscriptionStatus - listSamlAuthProviders: [ListSamlAuthProviders] + getPaddleInfo: GetPaddleInfo + getPaymentPlans: [PaymentPlan] + getPermissionCatalog: PermissionCatalog + getRole(id: String!): Role + getRoles: [Role] getSamlAuthProvider: SamlAuthProvider - getUsers(limit: Int!, offset: Int!): UserConnection + getSubscriptionStatus: GetSubscriptionStatus + getTrialStatus: GetTrialStatus getUser(id: String!): User - getRoles: [Role] - getRole(id: String!): Role - getPermissionCatalog: PermissionCatalog + getUsers(limit: Int!, offset: Int!): UserConnection healthcheck: AppHealth + listSamlAuthProviders: [ListSamlAuthProviders] } type Mutation { diff --git a/packages/backend/src/helpers/authentication.ts b/packages/backend/src/helpers/authentication.ts index de8ab5f0ed..d57e80c4ad 100644 --- a/packages/backend/src/helpers/authentication.ts +++ b/packages/backend/src/helpers/authentication.ts @@ -36,6 +36,7 @@ const authentication = shield( getAutomatischInfo: allow, listSamlAuthProviders: allow, healthcheck: allow, + getConfig: allow, }, Mutation: { '*': isAuthenticated, From ef9359b208e3e841502933b82ce1b4e47b6fc527 Mon Sep 17 00:00:00 2001 From: Ali BARIN Date: Thu, 10 Aug 2023 14:32:22 +0000 Subject: [PATCH 3/6] feat: write updateConfig GQL mutation --- .../backend/src/graphql/mutation-resolvers.ts | 8 ++-- .../src/graphql/mutations/update-config.ts | 44 +++++++++++++++++++ packages/backend/src/graphql/schema.graphql | 3 +- 3 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 packages/backend/src/graphql/mutations/update-config.ts diff --git a/packages/backend/src/graphql/mutation-resolvers.ts b/packages/backend/src/graphql/mutation-resolvers.ts index 044b9f87c2..bd5e180ce1 100644 --- a/packages/backend/src/graphql/mutation-resolvers.ts +++ b/packages/backend/src/graphql/mutation-resolvers.ts @@ -17,6 +17,7 @@ import login from './mutations/login'; import registerUser from './mutations/register-user.ee'; import resetConnection from './mutations/reset-connection'; import resetPassword from './mutations/reset-password.ee'; +import updateConfig from './mutations/update-config'; import updateConnection from './mutations/update-connection'; import updateCurrentUser from './mutations/update-current-user'; import updateFlow from './mutations/update-flow'; @@ -24,8 +25,8 @@ import updateFlowStatus from './mutations/update-flow-status'; import updateRole from './mutations/update-role.ee'; import updateStep from './mutations/update-step'; import updateUser from './mutations/update-user.ee'; -import verifyConnection from './mutations/verify-connection'; import upsertSamlAuthProvider from './mutations/upsert-saml-auth-provider.ee'; +import verifyConnection from './mutations/verify-connection'; const mutationResolvers = { createConnection, @@ -47,15 +48,16 @@ const mutationResolvers = { registerUser, resetConnection, resetPassword, + updateConfig, updateConnection, updateCurrentUser, - updateUser, updateFlow, updateFlowStatus, updateRole, updateStep, - verifyConnection, + updateUser, upsertSamlAuthProvider, + verifyConnection, }; export default mutationResolvers; diff --git a/packages/backend/src/graphql/mutations/update-config.ts b/packages/backend/src/graphql/mutations/update-config.ts new file mode 100644 index 0000000000..867897da60 --- /dev/null +++ b/packages/backend/src/graphql/mutations/update-config.ts @@ -0,0 +1,44 @@ +import type { IJSONValue } from '@automatisch/types'; +import Config from '../../models/config'; +import Context from '../../types/express/context'; + +type Params = { + input: { + [index: string]: IJSONValue; + }; +}; + +const updateConfig = async (_parent: unknown, params: Params, context: Context) => { + context.currentUser.can('update', 'Config'); + + const config = params.input; + const configKeys = Object.keys(config); + const updates = []; + + for (const key of configKeys) { + const newValue = config[key]; + + const entryUpdate = Config + .query() + .insert({ + key, + value: { + data: newValue + } + }) + .onConflict('key') + .merge({ + value: { + data: newValue + } + }); + + updates.push(entryUpdate); + } + + await Promise.all(updates); + + return config; +}; + +export default updateConfig; diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index a2b189a36f..ce93a5bea8 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -72,6 +72,7 @@ type Mutation { registerUser(input: RegisterUserInput): User resetConnection(input: ResetConnectionInput): Connection resetPassword(input: ResetPasswordInput): Boolean + updateConfig(input: JSONObject): JSONObject updateConnection(input: UpdateConnectionInput): Connection updateCurrentUser(input: UpdateCurrentUserInput): User updateFlow(input: UpdateFlowInput): Flow @@ -79,8 +80,8 @@ type Mutation { updateRole(input: UpdateRoleInput): Role updateStep(input: UpdateStepInput): Step updateUser(input: UpdateUserInput): User - verifyConnection(input: VerifyConnectionInput): Connection upsertSamlAuthProvider(input: UpsertSamlAuthProviderInput): SamlAuthProvider + verifyConnection(input: VerifyConnectionInput): Connection } """ From b590f0f98f8bcf3ba5bfa48355b068f7598b51e1 Mon Sep 17 00:00:00 2001 From: Ali BARIN Date: Thu, 10 Aug 2023 18:07:24 +0000 Subject: [PATCH 4/6] feat: write useConfig hook --- .../web/src/graphql/mutations/update-config.ts | 7 +++++++ packages/web/src/graphql/queries/get-config.ts | 8 ++++++++ packages/web/src/hooks/useConfig.ts | 17 +++++++++++++++++ 3 files changed, 32 insertions(+) create mode 100644 packages/web/src/graphql/mutations/update-config.ts create mode 100644 packages/web/src/graphql/queries/get-config.ts create mode 100644 packages/web/src/hooks/useConfig.ts diff --git a/packages/web/src/graphql/mutations/update-config.ts b/packages/web/src/graphql/mutations/update-config.ts new file mode 100644 index 0000000000..f74e61e3fb --- /dev/null +++ b/packages/web/src/graphql/mutations/update-config.ts @@ -0,0 +1,7 @@ +import { gql } from '@apollo/client'; + +export const UPDATE_CONFIG = gql` + mutation UpdateConfig($input: JSONObject) { + updateConfig(input: $input) + } +`; diff --git a/packages/web/src/graphql/queries/get-config.ts b/packages/web/src/graphql/queries/get-config.ts new file mode 100644 index 0000000000..6204b5a642 --- /dev/null +++ b/packages/web/src/graphql/queries/get-config.ts @@ -0,0 +1,8 @@ +import { gql } from '@apollo/client'; + +export const GET_CONFIG = gql` + query GetConfig($keys: [String]) { + getConfig(keys: $keys) + } +`; + diff --git a/packages/web/src/hooks/useConfig.ts b/packages/web/src/hooks/useConfig.ts new file mode 100644 index 0000000000..eaded7361c --- /dev/null +++ b/packages/web/src/hooks/useConfig.ts @@ -0,0 +1,17 @@ +import { useQuery } from '@apollo/client'; +import { IJSONObject } from '@automatisch/types'; + +import { GET_CONFIG } from 'graphql/queries/get-config'; + +type QueryResponse = { + getConfig: IJSONObject; +} + +export default function useConfig(keys?: string[]) { + const { data, loading } = useQuery(GET_CONFIG, { variables: { keys } }); + + return { + config: data?.getConfig, + loading, + }; +} From f6c500c998dc19344711437f416ffb15d0bb26a9 Mon Sep 17 00:00:00 2001 From: Ali BARIN Date: Thu, 10 Aug 2023 18:07:48 +0000 Subject: [PATCH 5/6] feat: use dynamic custom theme --- .../src/components/ThemeProvider/index.tsx | 37 +++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/web/src/components/ThemeProvider/index.tsx b/packages/web/src/components/ThemeProvider/index.tsx index adecee3628..f6562d805c 100644 --- a/packages/web/src/components/ThemeProvider/index.tsx +++ b/packages/web/src/components/ThemeProvider/index.tsx @@ -1,18 +1,49 @@ -import * as React from 'react'; -import { ThemeProvider as BaseThemeProvider } from '@mui/material/styles'; import CssBaseline from '@mui/material/CssBaseline'; +import { ThemeProvider as BaseThemeProvider } from '@mui/material/styles'; +import get from 'lodash/get'; +import set from 'lodash/set'; +import * as React from 'react'; + +import { IJSONObject } from '@automatisch/types'; +import useConfig from 'hooks/useConfig'; import theme from 'styles/theme'; type ThemeProviderProps = { children: React.ReactNode; }; +const customizeTheme = (defaultTheme: typeof theme, config: IJSONObject) => { + for (const key in config) { + const value = config[key]; + const exists = get(defaultTheme, key); + + if (exists) { + set(defaultTheme, key, value); + } + } + + return defaultTheme; +}; + const ThemeProvider = ({ children, ...props }: ThemeProviderProps): React.ReactElement => { + const { config, loading } = useConfig(); + + const customTheme = React.useMemo(() => { + if (!config) return theme; + + const customTheme = customizeTheme(theme, config); + + return customTheme; + }, [config]); + + // TODO: maybe a global loading state for the custom theme? + if (loading) return <>; + return ( - + {children} From 03ea61ba8111ee4775bc7af5815a63c8081e310c Mon Sep 17 00:00:00 2001 From: Ali BARIN Date: Thu, 10 Aug 2023 18:08:19 +0000 Subject: [PATCH 6/6] feat: use dynamic custom logo --- packages/web/src/components/AppBar/index.tsx | 28 +++++++++---------- .../src/components/CustomLogo/index.ee.tsx | 15 ++++++++++ packages/web/src/components/Logo/index.tsx | 23 +++++++++++++++ packages/web/src/components/Logo/style.ts | 8 ++++++ .../web/src/components/PublicLayout/index.tsx | 12 ++------ 5 files changed, 61 insertions(+), 25 deletions(-) create mode 100644 packages/web/src/components/CustomLogo/index.ee.tsx create mode 100644 packages/web/src/components/Logo/index.tsx create mode 100644 packages/web/src/components/Logo/style.ts diff --git a/packages/web/src/components/AppBar/index.tsx b/packages/web/src/components/AppBar/index.tsx index 9577252c23..66b006349c 100644 --- a/packages/web/src/components/AppBar/index.tsx +++ b/packages/web/src/components/AppBar/index.tsx @@ -1,20 +1,20 @@ -import * as React from 'react'; +import AccountCircleIcon from '@mui/icons-material/AccountCircle'; +import MenuIcon from '@mui/icons-material/Menu'; +import MenuOpenIcon from '@mui/icons-material/MenuOpen'; +import MuiAppBar from '@mui/material/AppBar'; import type { ContainerProps } from '@mui/material/Container'; +import IconButton from '@mui/material/IconButton'; +import Toolbar from '@mui/material/Toolbar'; import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import MuiAppBar from '@mui/material/AppBar'; -import Toolbar from '@mui/material/Toolbar'; -import IconButton from '@mui/material/IconButton'; -import Typography from '@mui/material/Typography'; -import MenuIcon from '@mui/icons-material/Menu'; -import MenuOpenIcon from '@mui/icons-material/MenuOpen'; -import AccountCircleIcon from '@mui/icons-material/AccountCircle'; +import * as React from 'react'; -import * as URLS from 'config/urls'; import AccountDropdownMenu from 'components/AccountDropdownMenu'; -import TrialStatusBadge from 'components/TrialStatusBadge/index.ee'; import Container from 'components/Container'; -import { FormattedMessage } from 'react-intl'; +import Logo from 'components/Logo/index'; +import TrialStatusBadge from 'components/TrialStatusBadge/index.ee'; +import * as URLS from 'config/urls'; + import { Link } from './style'; type AppBarProps = { @@ -60,11 +60,9 @@ export default function AppBar(props: AppBarProps): React.ReactElement { {drawerOpen && matchSmallScreens ? : } -
+
- - - +
diff --git a/packages/web/src/components/CustomLogo/index.ee.tsx b/packages/web/src/components/CustomLogo/index.ee.tsx new file mode 100644 index 0000000000..b43a85b423 --- /dev/null +++ b/packages/web/src/components/CustomLogo/index.ee.tsx @@ -0,0 +1,15 @@ +import useConfig from 'hooks/useConfig'; + +const CustomLogo = () => { + const { config, loading } = useConfig(['logo.svgData']); + + if (loading || !config?.['logo.svgData']) return null; + + const logoSvgData = config['logo.svgData'] as string; + + return ( + + ); +}; + +export default CustomLogo; diff --git a/packages/web/src/components/Logo/index.tsx b/packages/web/src/components/Logo/index.tsx new file mode 100644 index 0000000000..bf20aa2e72 --- /dev/null +++ b/packages/web/src/components/Logo/index.tsx @@ -0,0 +1,23 @@ +import Typography from '@mui/material/Typography'; +import * as React from 'react'; +import { FormattedMessage } from 'react-intl'; + +import CustomLogo from 'components/CustomLogo/index.ee'; +import useConfig from 'hooks/useConfig'; + +const Logo = () => { + const { config, loading } = useConfig(['logo.svgData']); + + const logoSvgData = config?.['logo.svgData'] as string; + if (loading && !logoSvgData) return (); + + if (logoSvgData) return ; + + return ( + + + + ); +}; + +export default Logo; diff --git a/packages/web/src/components/Logo/style.ts b/packages/web/src/components/Logo/style.ts new file mode 100644 index 0000000000..5c44684270 --- /dev/null +++ b/packages/web/src/components/Logo/style.ts @@ -0,0 +1,8 @@ +import { styled } from '@mui/material/styles'; +import { Link as RouterLink } from 'react-router-dom'; + +export const Link = styled(RouterLink)(() => ({ + textDecoration: 'none', + color: 'inherit', + display: 'inline-flex', +})); diff --git a/packages/web/src/components/PublicLayout/index.tsx b/packages/web/src/components/PublicLayout/index.tsx index ef6a8b23fb..cb44ba0f8b 100644 --- a/packages/web/src/components/PublicLayout/index.tsx +++ b/packages/web/src/components/PublicLayout/index.tsx @@ -3,10 +3,9 @@ import * as React from 'react'; import Toolbar from '@mui/material/Toolbar'; import AppBar from '@mui/material/AppBar'; import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; +import Logo from 'components/Logo'; import Container from 'components/Container'; -import { FormattedMessage } from 'react-intl'; type LayoutProps = { children: React.ReactNode; @@ -18,14 +17,7 @@ export default function Layout({ children }: LayoutProps): React.ReactElement { - - - +