feat: Add ability to configure and test SMTP via UI
This commit is contained in:
@@ -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: () => <UsersPane />,
|
||||
},
|
||||
{
|
||||
menuItem: t('common.webhooks', {
|
||||
];
|
||||
if (config.smtpHost !== undefined) {
|
||||
panes.push({
|
||||
menuItem: t('common.smtp', {
|
||||
context: 'title',
|
||||
}),
|
||||
render: () => <WebhooksPane />,
|
||||
},
|
||||
];
|
||||
render: () => <SmtpPane />,
|
||||
});
|
||||
}
|
||||
panes.push({
|
||||
menuItem: t('common.webhooks', {
|
||||
context: 'title',
|
||||
}),
|
||||
render: () => <WebhooksPane />,
|
||||
});
|
||||
|
||||
const isUsersPaneActive = activeTabIndex === 0;
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Tab.Pane attached={false} className={styles.wrapper}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<div className={styles.text}>{t('common.host')}</div>
|
||||
<Input
|
||||
fluid
|
||||
name="smtpHost"
|
||||
value={data.smtpHost}
|
||||
maxLength={256}
|
||||
className={styles.field}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
<div className={styles.text}>{t('common.port')}</div>
|
||||
<Input
|
||||
fluid
|
||||
type="number"
|
||||
name="smtpPort"
|
||||
value={data.smtpPort}
|
||||
placeholder={data.smtpSecure ? '465' : '587'}
|
||||
min={0}
|
||||
max={65535}
|
||||
step={1}
|
||||
className={styles.field}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
<div className={styles.text}>
|
||||
{t('common.clientHostnameInEhlo')} (
|
||||
{t('common.optional', {
|
||||
context: 'inline',
|
||||
})}
|
||||
)
|
||||
</div>
|
||||
<Input
|
||||
fluid
|
||||
name="smtpName"
|
||||
value={data.smtpName}
|
||||
maxLength={256}
|
||||
className={styles.field}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
<Checkbox
|
||||
name="smtpSecure"
|
||||
checked={data.smtpSecure}
|
||||
label={t('common.useSecureConnection')}
|
||||
className={styles.checkbox}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
<Checkbox
|
||||
name="smtpTlsRejectUnauthorized"
|
||||
checked={data.smtpTlsRejectUnauthorized}
|
||||
label={t('common.rejectUnauthorizedTlsCertificates')}
|
||||
className={classNames(styles.field, styles.checkbox)}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
<div className={styles.text}>
|
||||
{t('common.username')} (
|
||||
{t('common.optional', {
|
||||
context: 'inline',
|
||||
})}
|
||||
)
|
||||
</div>
|
||||
<Input
|
||||
fluid
|
||||
name="smtpUser"
|
||||
value={data.smtpUser}
|
||||
maxLength={256}
|
||||
className={styles.field}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
<div className={styles.text}>
|
||||
{t('common.password')} (
|
||||
{t('common.optional', {
|
||||
context: 'inline',
|
||||
})}
|
||||
)
|
||||
</div>
|
||||
<Input.Password
|
||||
fluid
|
||||
ref={handlePasswordFieldRef}
|
||||
name="smtpPassword"
|
||||
value={data.smtpPassword}
|
||||
placeholder={isPasswordSet ? t('common.passwordIsSet') : undefined}
|
||||
maxLength={256}
|
||||
className={styles.field}
|
||||
onClear={!data.smtpPassword && isPasswordSet ? handlePasswordClear : undefined}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
<div className={styles.text}>
|
||||
{t('common.defaultFrom')} (
|
||||
{t('common.optional', {
|
||||
context: 'inline',
|
||||
})}
|
||||
)
|
||||
</div>
|
||||
<Input
|
||||
fluid
|
||||
name="smtpFrom"
|
||||
value={data.smtpFrom}
|
||||
maxLength={256}
|
||||
className={styles.field}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
<div className={styles.controls}>
|
||||
<Button positive disabled={!isModified} content={t('action.save')} />
|
||||
{config.smtpHost && !isModified && (
|
||||
<Button
|
||||
type="button"
|
||||
content={t('action.sendTestEmail')}
|
||||
loading={smtpTest.isLoading}
|
||||
disabled={smtpTest.isLoading}
|
||||
onClick={handleTestClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
{smtpTest.logs && (
|
||||
<>
|
||||
<Divider horizontal>
|
||||
<Header as="h4">
|
||||
{t('common.testLog', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Header>
|
||||
</Divider>
|
||||
<TextArea
|
||||
readOnly
|
||||
as={TextareaAutosize}
|
||||
value={smtpTest.logs.join('\n')}
|
||||
className={styles.testLog}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Tab.Pane>
|
||||
);
|
||||
});
|
||||
|
||||
export default SmtpPane;
|
||||
@@ -0,0 +1,41 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.checkbox {
|
||||
display: block;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.testLog {
|
||||
border: 1px solid rgba(9, 30, 66, 0.13);
|
||||
border-radius: 3px;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
padding: 8px 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: #444444;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,8 @@ const UsersPane = React.memo(() => {
|
||||
const activeUsersTotal = useSelector(selectors.selectActiveUsersTotal);
|
||||
|
||||
const canAdd = useSelector((state) => {
|
||||
const oidcConfig = selectors.selectOidcConfig(state);
|
||||
return !oidcConfig || !oidcConfig.isEnforced;
|
||||
const oidcBootstrap = selectors.selectOidcBootstrap(state);
|
||||
return !oidcBootstrap || !oidcBootstrap.isEnforced;
|
||||
});
|
||||
|
||||
const [t] = useTranslation();
|
||||
|
||||
@@ -30,8 +30,8 @@ const Core = React.memo(() => {
|
||||
|
||||
// TODO: move to selector?
|
||||
const isNewVersionAvailable = useSelector((state) => {
|
||||
const config = selectors.selectConfig(state);
|
||||
return !!config && config.version !== version;
|
||||
const bootstrap = selectors.selectBootstrap(state);
|
||||
return !!bootstrap && bootstrap.version !== version;
|
||||
});
|
||||
|
||||
const [t] = useTranslation();
|
||||
|
||||
@@ -86,7 +86,7 @@ const createMessage = (error) => {
|
||||
};
|
||||
|
||||
const Content = React.memo(() => {
|
||||
const config = useSelector(selectors.selectConfig);
|
||||
const bootstrap = useSelector(selectors.selectBootstrap);
|
||||
|
||||
const {
|
||||
data: defaultData,
|
||||
@@ -139,8 +139,8 @@ const Content = React.memo(() => {
|
||||
dispatch(entryActions.clearAuthenticateError());
|
||||
}, [dispatch]);
|
||||
|
||||
const withOidc = !!config.oidc;
|
||||
const isOidcEnforced = withOidc && config.oidc.isEnforced;
|
||||
const withOidc = !!bootstrap.oidc;
|
||||
const isOidcEnforced = withOidc && bootstrap.oidc.isEnforced;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOidcEnforced) {
|
||||
|
||||
Reference in New Issue
Block a user