diff --git a/charts/planka/values.yaml b/charts/planka/values.yaml index 8b084ff8..27bd67c1 100644 --- a/charts/planka/values.yaml +++ b/charts/planka/values.yaml @@ -224,11 +224,11 @@ extraEnv: [] ## value: "Your Name" ## - name: SMTP_SECURE ## value: "true" +## - name: SMTP_TLS_REJECT_UNAUTHORIZED +## value: "false" ## - name: SMTP_USER ## value: "your_email@example.com" ## - name: SMTP_PASSWORD ## value: "your_password" ## - name: SMTP_FROM ## value: "your_email@example.com" -## - name: SMTP_TLS_REJECT_UNAUTHORIZED -## value: "false" diff --git a/client/src/actions/config.js b/client/src/actions/config.js new file mode 100644 index 00000000..97a694cc --- /dev/null +++ b/client/src/actions/config.js @@ -0,0 +1,59 @@ +/*! + * Copyright (c) 2024 PLANKA Software GmbH + * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md + */ + +import ActionTypes from '../constants/ActionTypes'; + +const updateConfig = (data) => ({ + type: ActionTypes.CONFIG_UPDATE, + payload: { + data, + }, +}); + +updateConfig.success = (config) => ({ + type: ActionTypes.CONFIG_UPDATE__SUCCESS, + payload: { + config, + }, +}); + +updateConfig.failure = (error) => ({ + type: ActionTypes.CONFIG_UPDATE__FAILURE, + payload: { + error, + }, +}); + +const handleConfigUpdate = (config) => ({ + type: ActionTypes.CONFIG_UPDATE_HANDLE, + payload: { + config, + }, +}); + +const testSmtpConfig = () => ({ + type: ActionTypes.SMTP_CONFIG_TEST, + payload: {}, +}); + +testSmtpConfig.success = (logs) => ({ + type: ActionTypes.SMTP_CONFIG_TEST__SUCCESS, + payload: { + logs, + }, +}); + +testSmtpConfig.failure = (error) => ({ + type: ActionTypes.SMTP_CONFIG_TEST__FAILURE, + payload: { + error, + }, +}); + +export default { + updateConfig, + handleConfigUpdate, + testSmtpConfig, +}; diff --git a/client/src/actions/core.js b/client/src/actions/core.js index a2f05f4a..85182ffd 100644 --- a/client/src/actions/core.js +++ b/client/src/actions/core.js @@ -6,6 +6,7 @@ import ActionTypes from '../constants/ActionTypes'; const initializeCore = ( + config, user, board, webhooks, @@ -32,6 +33,7 @@ const initializeCore = ( ) => ({ type: ActionTypes.CORE_INITIALIZE, payload: { + config, user, board, webhooks, @@ -58,10 +60,10 @@ const initializeCore = ( }, }); -initializeCore.fetchConfig = (config) => ({ - type: ActionTypes.CORE_INITIALIZE__CONFIG_FETCH, +initializeCore.fetchBootstrap = (bootstrap) => ({ + type: ActionTypes.CORE_INITIALIZE__BOOTSTRAP_FETCH, payload: { - config, + bootstrap, }, }); diff --git a/client/src/actions/index.js b/client/src/actions/index.js index 46a254a1..fcf3a702 100644 --- a/client/src/actions/index.js +++ b/client/src/actions/index.js @@ -8,6 +8,7 @@ import socket from './socket'; import login from './login'; import core from './core'; import modals from './modals'; +import config from './config'; import webhooks from './webhooks'; import users from './users'; import projects from './projects'; @@ -36,6 +37,7 @@ export default { ...login, ...core, ...modals, + ...config, ...webhooks, ...users, ...projects, diff --git a/client/src/actions/login.js b/client/src/actions/login.js index 67e31e11..da0ccaa1 100644 --- a/client/src/actions/login.js +++ b/client/src/actions/login.js @@ -5,10 +5,10 @@ import ActionTypes from '../constants/ActionTypes'; -const initializeLogin = (config) => ({ +const initializeLogin = (bootstrap) => ({ type: ActionTypes.LOGIN_INITIALIZE, payload: { - config, + bootstrap, }, }); diff --git a/client/src/actions/socket.js b/client/src/actions/socket.js index 8e6f07f4..16dfd77f 100644 --- a/client/src/actions/socket.js +++ b/client/src/actions/socket.js @@ -11,6 +11,7 @@ const handleSocketDisconnect = () => ({ }); const handleSocketReconnect = ( + bootstrap, config, user, board, @@ -38,6 +39,7 @@ const handleSocketReconnect = ( ) => ({ type: ActionTypes.SOCKET_RECONNECT_HANDLE, payload: { + bootstrap, config, user, board, diff --git a/client/src/actions/users.js b/client/src/actions/users.js index 101d65c6..1b2ece4a 100644 --- a/client/src/actions/users.js +++ b/client/src/actions/users.js @@ -65,6 +65,7 @@ const handleUserUpdate = ( user, projectIds, boardIds, + bootstrap, config, board, webhooks, @@ -94,6 +95,7 @@ const handleUserUpdate = ( user, projectIds, boardIds, + bootstrap, config, board, webhooks, diff --git a/client/src/api/bootstrap.js b/client/src/api/bootstrap.js new file mode 100644 index 00000000..4253466f --- /dev/null +++ b/client/src/api/bootstrap.js @@ -0,0 +1,14 @@ +/*! + * Copyright (c) 2024 PLANKA Software GmbH + * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md + */ + +import http from './http'; + +/* Actions */ + +const getBootstrap = (headers) => http.get('/bootstrap', undefined, headers); + +export default { + getBootstrap, +}; diff --git a/client/src/api/config.js b/client/src/api/config.js index 65577881..ea127a17 100644 --- a/client/src/api/config.js +++ b/client/src/api/config.js @@ -3,12 +3,18 @@ * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md */ -import http from './http'; +import socket from './socket'; /* Actions */ -const getConfig = (headers) => http.get('/config', undefined, headers); +const getConfig = (headers) => socket.get('/config', undefined, headers); + +const updateConfig = (data, headers) => socket.patch('/config', data, headers); + +const testSmtpConfig = (headers) => socket.post('/config/test-smtp', undefined, headers); export default { getConfig, + updateConfig, + testSmtpConfig, }; diff --git a/client/src/api/index.js b/client/src/api/index.js index 98d045f1..eea95c26 100755 --- a/client/src/api/index.js +++ b/client/src/api/index.js @@ -5,9 +5,10 @@ import http from './http'; import socket from './socket'; -import config from './config'; +import bootstrap from './bootstrap'; import terms from './terms'; import accessTokens from './access-tokens'; +import config from './config'; import webhooks from './webhooks'; import users from './users'; import projects from './projects'; @@ -35,9 +36,10 @@ import notificationServices from './notification-services'; export { http, socket }; export default { - ...config, + ...bootstrap, ...terms, ...accessTokens, + ...config, ...webhooks, ...users, ...projects, diff --git a/client/src/components/common/AdministrationModal/AdministrationModal.jsx b/client/src/components/common/AdministrationModal/AdministrationModal.jsx index d79d22ec..01fe2aa9 100644 --- a/client/src/components/common/AdministrationModal/AdministrationModal.jsx +++ b/client/src/components/common/AdministrationModal/AdministrationModal.jsx @@ -5,18 +5,22 @@ import React, { useCallback, useState } from 'react'; import classNames from 'classnames'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { useTranslation } from 'react-i18next'; import { Modal, Tab } from 'semantic-ui-react'; +import selectors from '../../../selectors'; import entryActions from '../../../entry-actions'; import { useClosableModal } from '../../../hooks'; import UsersPane from './UsersPane'; +import SmtpPane from './SmtpPane'; import WebhooksPane from './WebhooksPane'; import styles from './AdministrationModal.module.scss'; const AdministrationModal = React.memo(() => { + const config = useSelector(selectors.selectConfig); + const dispatch = useDispatch(); const [t] = useTranslation(); const [activeTabIndex, setActiveTabIndex] = useState(0); @@ -38,13 +42,21 @@ const AdministrationModal = React.memo(() => { }), render: () => , }, - { - menuItem: t('common.webhooks', { + ]; + if (config.smtpHost !== undefined) { + panes.push({ + menuItem: t('common.smtp', { context: 'title', }), - render: () => , - }, - ]; + render: () => , + }); + } + panes.push({ + menuItem: t('common.webhooks', { + context: 'title', + }), + render: () => , + }); const isUsersPaneActive = activeTabIndex === 0; diff --git a/client/src/components/common/AdministrationModal/SmtpPane.jsx b/client/src/components/common/AdministrationModal/SmtpPane.jsx new file mode 100644 index 00000000..80d8955a --- /dev/null +++ b/client/src/components/common/AdministrationModal/SmtpPane.jsx @@ -0,0 +1,227 @@ +/*! + * Copyright (c) 2024 PLANKA Software GmbH + * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md + */ + +import { dequal } from 'dequal'; +import React, { useCallback, useMemo } from 'react'; +import classNames from 'classnames'; +import { useDispatch, useSelector } from 'react-redux'; +import { useTranslation } from 'react-i18next'; +import TextareaAutosize from 'react-textarea-autosize'; +import { Button, Checkbox, Divider, Form, Header, Tab, TextArea } from 'semantic-ui-react'; +import { Input } from '../../../lib/custom-ui'; + +import selectors from '../../../selectors'; +import entryActions from '../../../entry-actions'; +import { useForm, useNestedRef } from '../../../hooks'; + +import styles from './SmtpPane.module.scss'; + +const SmtpPane = React.memo(() => { + const config = useSelector(selectors.selectConfig); + const smtpTest = useSelector(selectors.selectSmtpTest); + + const dispatch = useDispatch(); + const [t] = useTranslation(); + + const [passwordFieldRef, handlePasswordFieldRef] = useNestedRef('inputRef'); + + const defaultData = useMemo( + () => ({ + smtpHost: config.smtpHost, + smtpPort: config.smtpPort, + smtpName: config.smtpName, + smtpSecure: config.smtpSecure, + smtpTlsRejectUnauthorized: config.smtpTlsRejectUnauthorized, + smtpUser: config.smtpUser, + smtpPassword: config.smtpPassword, + smtpFrom: config.smtpFrom, + }), + [config], + ); + + const [data, handleFieldChange] = useForm(() => ({ + ...defaultData, + smtpHost: defaultData.smtpHost || '', + smtpPort: defaultData.smtpPort || '', + smtpName: defaultData.smtpName || '', + smtpSecure: defaultData.smtpSecure, + smtpTlsRejectUnauthorized: defaultData.smtpTlsRejectUnauthorized, + smtpUser: defaultData.smtpUser || '', + smtpPassword: defaultData.smtpPassword || '', + smtpFrom: defaultData.smtpFrom || '', + })); + + const isPasswordSet = defaultData.smtpPassword === undefined; + + const cleanData = useMemo( + () => ({ + ...data, + smtpHost: data.smtpHost.trim() || null, + smtpPort: parseInt(data.smtpPort, 10) || null, + smtpName: data.smtpName.trim() || null, + smtpUser: data.smtpUser.trim() || null, + smtpPassword: data.smtpPassword || (isPasswordSet ? undefined : null), + smtpFrom: data.smtpFrom.trim() || null, + }), + [data, isPasswordSet], + ); + + const handleSubmit = useCallback(() => { + dispatch(entryActions.updateConfig(cleanData)); + }, [dispatch, cleanData]); + + const handlePasswordClear = useCallback(() => { + dispatch( + entryActions.updateConfig({ + smtpPassword: null, + }), + ); + + passwordFieldRef.current.focus(); + }, [dispatch, passwordFieldRef]); + + const handleTestClick = useCallback(() => { + dispatch(entryActions.testSmtpConfig()); + }, [dispatch]); + + const isModified = !dequal(cleanData, defaultData); + + return ( + +
+
{t('common.host')}
+ +
{t('common.port')}
+ +
+ {t('common.clientHostnameInEhlo')} ( + {t('common.optional', { + context: 'inline', + })} + ) +
+ + + +
+ {t('common.username')} ( + {t('common.optional', { + context: 'inline', + })} + ) +
+ +
+ {t('common.password')} ( + {t('common.optional', { + context: 'inline', + })} + ) +
+ +
+ {t('common.defaultFrom')} ( + {t('common.optional', { + context: 'inline', + })} + ) +
+ +
+
+ + {smtpTest.logs && ( + <> + +
+ {t('common.testLog', { + context: 'title', + })} +
+
+