diff --git a/frontend/src/metabase/reducers-admin.js b/frontend/src/metabase/admin/admin.js similarity index 67% rename from frontend/src/metabase/reducers-admin.js rename to frontend/src/metabase/admin/admin.js index 5ee2bfab4bccf8b62087d475e3c959784e4ff6fa..3fafa57f7edcf894693e18488ea11c142ccd72e1 100644 --- a/frontend/src/metabase/reducers-admin.js +++ b/frontend/src/metabase/admin/admin.js @@ -6,10 +6,14 @@ import people from "metabase/admin/people/people"; import databases from "metabase/admin/databases/database"; import datamodel from "metabase/admin/datamodel/datamodel"; import permissions from "metabase/admin/permissions/permissions"; +import settings from "metabase/admin/settings/settings"; -export default { +import { combineReducers } from "metabase/lib/redux"; + +export default combineReducers({ databases, - datamodel: datamodel, + datamodel, people, permissions, -}; + settings +}) diff --git a/frontend/src/metabase/admin/databases/selectors.js b/frontend/src/metabase/admin/databases/selectors.js index 79a714068df40c20b5da3e0579fafdd89841ca46..e9371fb171f2463bf258134b5f7c2272a5f26e5d 100644 --- a/frontend/src/metabase/admin/databases/selectors.js +++ b/frontend/src/metabase/admin/databases/selectors.js @@ -5,7 +5,7 @@ import { createSelector } from 'reselect'; // Database List -export const databases = state => state.databases.databases; +export const databases = state => state.admin.databases.databases; export const getDatabasesSorted = createSelector( [databases], @@ -19,5 +19,5 @@ export const hasSampleDataset = createSelector( // Database Edit -export const getEditingDatabase = state => state.databases.editingDatabase; -export const getFormState = state => state.databases.formState; +export const getEditingDatabase = state => state.admin.databases.editingDatabase; +export const getFormState = state => state.admin.databases.formState; diff --git a/frontend/src/metabase/admin/datamodel/datamodel.js b/frontend/src/metabase/admin/datamodel/datamodel.js index dfed708e8e75b371139a1e46da5a759bb337dfe4..0dafaacccd0e8920d49e9681a8da04c7d02f370c 100644 --- a/frontend/src/metabase/admin/datamodel/datamodel.js +++ b/frontend/src/metabase/admin/datamodel/datamodel.js @@ -9,12 +9,15 @@ import { isFK } from "metabase/lib/types"; import { MetabaseApi, SegmentApi, MetricApi, RevisionsApi } from "metabase/services"; +import { getEditingDatabase } from "./selectors"; + function loadDatabaseMetadata(databaseId) { return MetabaseApi.db_metadata({ 'dbId': databaseId }); } // initializeMetadata -export const initializeMetadata = createThunkAction("INITIALIZE_METADATA", function(databaseId, tableId) { +export const INITIALIZE_METADATA = "metabase/admin/datamodel/INITIALIZE_METADATA"; +export const initializeMetadata = createThunkAction(INITIALIZE_METADATA, function(databaseId, tableId) { return async function(dispatch, getState) { let databases, database; try { @@ -43,7 +46,8 @@ export const initializeMetadata = createThunkAction("INITIALIZE_METADATA", funct }); // fetchDatabaseIdfields -export const fetchDatabaseIdfields = createThunkAction("FETCH_IDFIELDS", function(databaseId) { +export const FETCH_IDFIELDS = "metabase/admin/datamodel/FETCH_IDFIELDS"; +export const fetchDatabaseIdfields = createThunkAction(FETCH_IDFIELDS, function(databaseId) { return async function(dispatch, getState) { try { let idfields = await MetabaseApi.db_idfields({ 'dbId': databaseId }); @@ -58,7 +62,8 @@ export const fetchDatabaseIdfields = createThunkAction("FETCH_IDFIELDS", functio }); // selectDatabase -export const selectDatabase = createThunkAction("SELECT_DATABASE", function(db) { +export const SELECT_DATABASE = "metabase/admin/datamodel/SELECT_DATABASE"; +export const selectDatabase = createThunkAction(SELECT_DATABASE, function(db) { return async function(dispatch, getState) { try { let database = await loadDatabaseMetadata(db.id); @@ -76,7 +81,8 @@ export const selectDatabase = createThunkAction("SELECT_DATABASE", function(db) }); // selectTable -export const selectTable = createThunkAction("SELECT_TABLE", function(table) { +export const SELECT_TABLE = "metabase/admin/datamodel/SELECT_TABLE"; +export const selectTable = createThunkAction(SELECT_TABLE, function(table) { return function(dispatch, getState) { // we also want to update our url to match our new state dispatch(push('/admin/datamodel/database/'+table.db_id+'/table/'+table.id)); @@ -86,7 +92,8 @@ export const selectTable = createThunkAction("SELECT_TABLE", function(table) { }); // updateTable -export const updateTable = createThunkAction("UPDATE_TABLE", function(table) { +export const UPDATE_TABLE = "metabase/admin/datamodel/UPDATE_TABLE"; +export const updateTable = createThunkAction(UPDATE_TABLE, function(table) { return async function(dispatch, getState) { try { // make sure we don't send all the computed metadata @@ -109,9 +116,10 @@ export const updateTable = createThunkAction("UPDATE_TABLE", function(table) { }); // updateField -export const updateField = createThunkAction("UPDATE_FIELD", function(field) { +export const UPDATE_FIELD = "metabase/admin/datamodel/UPDATE_FIELD"; +export const updateField = createThunkAction(UPDATE_FIELD, function(field) { return async function(dispatch, getState) { - const { datamodel: { editingDatabase } } = getState(); + const editingDatabase = getEditingDatabase(getState()); try { // make sure we don't send all the computed metadata @@ -138,7 +146,8 @@ export const updateField = createThunkAction("UPDATE_FIELD", function(field) { }); // updateFieldSpecialType -export const updateFieldSpecialType = createThunkAction("UPDATE_FIELD_SPECIAL_TYPE", function(field) { +export const UPDATE_FIELD_SPECIAL_TYPE = "metabase/admin/datamodel/UPDATE_FIELD_SPECIAL_TYPE"; +export const updateFieldSpecialType = createThunkAction(UPDATE_FIELD_SPECIAL_TYPE, function(field) { return function(dispatch, getState) { // If we are changing the field from a FK to something else, we should delete any FKs present @@ -157,7 +166,8 @@ export const updateFieldSpecialType = createThunkAction("UPDATE_FIELD_SPECIAL_TY }); // updateFieldTarget -export const updateFieldTarget = createThunkAction("UPDATE_FIELD_TARGET", function(field) { +export const UPDATE_FIELD_TARGET = "metabase/admin/datamodel/UPDATE_FIELD_TARGET"; +export const updateFieldTarget = createThunkAction(UPDATE_FIELD_TARGET, function(field) { return function(dispatch, getState) { // This function notes a change in the target of the target of a foreign key dispatch(updateField(field)); @@ -167,9 +177,10 @@ export const updateFieldTarget = createThunkAction("UPDATE_FIELD_TARGET", functi }); // retireSegment -export const onRetireSegment = createThunkAction("RETIRE_SEGMENT", function(segment) { +export const RETIRE_SEGMENT = "metabase/admin/datamodel/RETIRE_SEGMENT"; +export const onRetireSegment = createThunkAction(RETIRE_SEGMENT, function(segment) { return async function(dispatch, getState) { - const { datamodel: { editingDatabase } } = getState(); + const editingDatabase = getEditingDatabase(getState()); await SegmentApi.delete(segment); MetabaseAnalytics.trackEvent("Data Model", "Retire Segment"); @@ -179,9 +190,10 @@ export const onRetireSegment = createThunkAction("RETIRE_SEGMENT", function(segm }); // retireMetric -export const onRetireMetric = createThunkAction("RETIRE_METRIC", function(metric) { +export const RETIRE_METRIC = "metabase/admin/datamodel/RETIRE_METRIC"; +export const onRetireMetric = createThunkAction(RETIRE_METRIC, function(metric) { return async function(dispatch, getState) { - const { datamodel: { editingDatabase } } = getState(); + const editingDatabase = getEditingDatabase(getState()); await MetricApi.delete(metric); MetabaseAnalytics.trackEvent("Data Model", "Retire Metric"); @@ -193,10 +205,10 @@ export const onRetireMetric = createThunkAction("RETIRE_METRIC", function(metric // SEGMENTS -export const GET_SEGMENT = "GET_SEGMENT"; -export const CREATE_SEGMENT = "CREATE_SEGMENT"; -export const UPDATE_SEGMENT = "UPDATE_SEGMENT"; -export const DELETE_SEGMENT = "DELETE_SEGMENT"; +export const GET_SEGMENT = "metabase/admin/datamodel/GET_SEGMENT"; +export const CREATE_SEGMENT = "metabase/admin/datamodel/CREATE_SEGMENT"; +export const UPDATE_SEGMENT = "metabase/admin/datamodel/UPDATE_SEGMENT"; +export const DELETE_SEGMENT = "metabase/admin/datamodel/DELETE_SEGMENT"; export const getSegment = createAction(GET_SEGMENT, SegmentApi.get); export const createSegment = createAction(CREATE_SEGMENT, SegmentApi.create); @@ -205,10 +217,10 @@ export const deleteSegment = createAction(DELETE_SEGMENT, SegmentApi.delete); // METRICS -export const GET_METRIC = "GET_METRIC"; -export const CREATE_METRIC = "CREATE_METRIC"; -export const UPDATE_METRIC = "UPDATE_METRIC"; -export const DELETE_METRIC = "DELETE_METRIC"; +export const GET_METRIC = "metabase/admin/datamodel/GET_METRIC"; +export const CREATE_METRIC = "metabase/admin/datamodel/CREATE_METRIC"; +export const UPDATE_METRIC = "metabase/admin/datamodel/UPDATE_METRIC"; +export const DELETE_METRIC = "metabase/admin/datamodel/DELETE_METRIC"; export const getMetric = createAction(GET_METRIC, MetricApi.get); export const createMetric = createAction(CREATE_METRIC, MetricApi.create); @@ -217,8 +229,8 @@ export const deleteMetric = createAction(DELETE_METRIC, MetricApi.delete); // SEGMENT DETAIL -export const LOAD_TABLE_METADATA = "LOAD_TABLE_METADATA"; -export const UPDATE_PREVIEW_SUMMARY = "UPDATE_PREVIEW_SUMMARY"; +export const LOAD_TABLE_METADATA = "metabase/admin/datamodel/LOAD_TABLE_METADATA"; +export const UPDATE_PREVIEW_SUMMARY = "metabase/admin/datamodel/UPDATE_PREVIEW_SUMMARY"; export const loadTableMetadata = createAction(LOAD_TABLE_METADATA, loadTableAndForeignKeys); export const updatePreviewSummary = createAction(UPDATE_PREVIEW_SUMMARY, async (query) => { @@ -228,7 +240,7 @@ export const updatePreviewSummary = createAction(UPDATE_PREVIEW_SUMMARY, async ( // REVISION HISTORY -export const FETCH_REVISIONS = "FETCH_REVISIONS"; +export const FETCH_REVISIONS = "metabase/admin/datamodel/FETCH_REVISIONS"; export const fetchRevisions = createThunkAction(FETCH_REVISIONS, ({ entity, id }) => async (dispatch, getState) => { @@ -250,23 +262,23 @@ export const fetchRevisions = createThunkAction(FETCH_REVISIONS, ({ entity, id } // reducers const databases = handleActions({ - ["INITIALIZE_METADATA"]: { next: (state, { payload }) => payload.databases } + [INITIALIZE_METADATA]: { next: (state, { payload }) => payload.databases } }, []); const idfields = handleActions({ - ["FETCH_IDFIELDS"]: { next: (state, { payload }) => payload ? payload : state } + [FETCH_IDFIELDS]: { next: (state, { payload }) => payload ? payload : state } }, []); const editingDatabase = handleActions({ - ["INITIALIZE_METADATA"]: { next: (state, { payload }) => payload.database }, - ["SELECT_DATABASE"]: { next: (state, { payload }) => payload ? payload : state }, - ["RETIRE_SEGMENT"]: { next: (state, { payload }) => payload }, - ["RETIRE_METRIC"]: { next: (state, { payload }) => payload } + [INITIALIZE_METADATA]: { next: (state, { payload }) => payload.database }, + [SELECT_DATABASE]: { next: (state, { payload }) => payload ? payload : state }, + [RETIRE_SEGMENT]: { next: (state, { payload }) => payload }, + [RETIRE_METRIC]: { next: (state, { payload }) => payload } }, null); const editingTable = handleActions({ - ["INITIALIZE_METADATA"]: { next: (state, { payload }) => payload.tableId || null }, - ["SELECT_TABLE"]: { next: (state, { payload }) => payload } + [INITIALIZE_METADATA]: { next: (state, { payload }) => payload.tableId || null }, + [SELECT_TABLE]: { next: (state, { payload }) => payload } }, null); const segments = handleActions({ diff --git a/frontend/src/metabase/admin/datamodel/selectors.js b/frontend/src/metabase/admin/datamodel/selectors.js index 2d957762ac9adfe9ce8289576d1bbf41f2d72800..5e782bc986437b37540130591b33abfc89363087 100644 --- a/frontend/src/metabase/admin/datamodel/selectors.js +++ b/frontend/src/metabase/admin/datamodel/selectors.js @@ -3,12 +3,12 @@ import { createSelector } from 'reselect'; import { computeMetadataStrength } from "metabase/lib/schema_metadata"; -const segmentsSelector = (state, props) => state.datamodel.segments; -const metricsSelector = (state, props) => state.datamodel.metrics; +const segmentsSelector = (state, props) => state.admin.datamodel.segments; +const metricsSelector = (state, props) => state.admin.datamodel.metrics; -const tableMetadataSelector = (state, props) => state.datamodel.tableMetadata; -const previewSummarySelector = (state, props) => state.datamodel.previewSummary; -const revisionObjectSelector = (state, props) => state.datamodel.revisionObject; +const tableMetadataSelector = (state, props) => state.admin.datamodel.tableMetadata; +const previewSummarySelector = (state, props) => state.admin.datamodel.previewSummary; +const revisionObjectSelector = (state, props) => state.admin.datamodel.revisionObject; const idSelector = (state, props) => props.params.id == null ? null : parseInt(props.params.id); const tableIdSelector = (state, props) => props.location.query.table == null ? null : parseInt(props.location.query.table); @@ -73,13 +73,14 @@ export const revisionHistorySelectors = createSelector( ); -export const getDatabases = (state, props) => state.datamodel.databases; -export const getDatabaseIdfields = (state, props) => state.datamodel.idfields; -export const getEditingTable = (state, props) => state.datamodel.editingTable; +export const getDatabases = (state, props) => state.admin.datamodel.databases; +export const getDatabaseIdfields = (state, props) => state.admin.datamodel.idfields; +export const getEditingTable = (state, props) => state.admin.datamodel.editingTable; +export const getEditingDatabase = (state, props) => state.admin.datamodel.editingDatabase; export const getEditingDatabaseWithTableMetadataStrengths = createSelector( - state => state.datamodel.editingDatabase, + state => state.admin.datamodel.editingDatabase, (database) => { if (!database || !database.tables) { return null; diff --git a/frontend/src/metabase/admin/people/selectors.js b/frontend/src/metabase/admin/people/selectors.js index 4ee7eabd4483201d1a818e905a63ef06476d2fec..05398a59dbee38f1ffbd211bbd450abac0c96df6 100644 --- a/frontend/src/metabase/admin/people/selectors.js +++ b/frontend/src/metabase/admin/people/selectors.js @@ -2,14 +2,14 @@ import { createSelector } from 'reselect'; import _ from "underscore"; -export const getGroups = (state) => state.people.groups; -export const getGroup = (state) => state.people.group; -export const getModal = (state) => state.people.modal; -export const getMemberships = (state) => state.people.memberships; +export const getGroups = (state) => state.admin.people.groups; +export const getGroup = (state) => state.admin.people.group; +export const getModal = (state) => state.admin.people.modal; +export const getMemberships = (state) => state.admin.people.memberships; export const getUsers = createSelector( - (state) => state.people.users, - (state) => state.people.memberships, + (state) => state.admin.people.users, + (state) => state.admin.people.memberships, (users, memberships) => users && _.mapObject(users, user => ({ ...user, diff --git a/frontend/src/metabase/admin/permissions/permissions.js b/frontend/src/metabase/admin/permissions/permissions.js index 0973b926ca316cd9617c62bf029dfeeb9c29e524..6fdc64cfe41699c360e012c638e3764502feb375 100644 --- a/frontend/src/metabase/admin/permissions/permissions.js +++ b/frontend/src/metabase/admin/permissions/permissions.js @@ -34,7 +34,7 @@ export const loadGroups = createAction(LOAD_GROUPS, () => PermissionsApi.groups( const LOAD_PERMISSIONS = "metabase/admin/permissions/LOAD_PERMISSIONS"; export const loadPermissions = createThunkAction(LOAD_PERMISSIONS, () => async (dispatch, getState) => { - const { load } = getState().permissions; + const { load } = getState().admin.permissions; return load(); } ); @@ -56,7 +56,7 @@ const SAVE_PERMISSIONS = "metabase/admin/permissions/SAVE_PERMISSIONS"; export const savePermissions = createThunkAction(SAVE_PERMISSIONS, () => async (dispatch, getState) => { MetabaseAnalytics.trackEvent("Permissions", "save"); - const { permissions, revision, save } = getState().permissions; + const { permissions, revision, save } = getState().admin.permissions; let result = await save({ revision: revision, groups: permissions diff --git a/frontend/src/metabase/admin/permissions/selectors.js b/frontend/src/metabase/admin/permissions/selectors.js index 85510bee59995fd0d021deae11d1dfd3e9201eef..efaad73d99df7353ee2fcb41a4ec6f042cffff39 100644 --- a/frontend/src/metabase/admin/permissions/selectors.js +++ b/frontend/src/metabase/admin/permissions/selectors.js @@ -27,14 +27,14 @@ import { diffPermissions, } from "metabase/lib/permissions"; -const getPermissions = (state) => state.permissions.permissions; -const getOriginalPermissions = (state) => state.permissions.originalPermissions; +const getPermissions = (state) => state.admin.permissions.permissions; +const getOriginalPermissions = (state) => state.admin.permissions.originalPermissions; const getDatabaseId = (state, props) => props.params.databaseId ? parseInt(props.params.databaseId) : null const getSchemaName = (state, props) => props.params.schemaName const getMetadata = createSelector( - [(state) => state.permissions.databases], + [(state) => state.admin.permissions.databases], (databases) => databases && new Metadata(databases) ); @@ -53,7 +53,7 @@ function getTooltipForGroup(group) { } export const getGroups = createSelector( - (state) => state.permissions.groups, + (state) => state.admin.permissions.groups, (groups) => { let orderedGroups = groups ? [...groups] : []; for (let groupFilter of SPECIAL_GROUP_FILTERS) { @@ -75,7 +75,7 @@ export const getIsDirty = createSelector( JSON.stringify(permissions) !== JSON.stringify(originalPermissions) ) -export const getSaveError = (state) => state.permissions.saveError; +export const getSaveError = (state) => state.admin.permissions.saveError; // these are all the permission levels ordered by level of access @@ -418,7 +418,7 @@ export const getDatabasesPermissionsGrid = createSelector( } ); -const getCollections = (state) => state.permissions.collections; +const getCollections = (state) => state.admin.permissions.collections; const getCollectionPermission = (permissions, groupId, { collectionId }) => getIn(permissions, [groupId, collectionId]) diff --git a/frontend/src/metabase/admin/settings/components/SettingsEmailForm.jsx b/frontend/src/metabase/admin/settings/components/SettingsEmailForm.jsx index de3639693cb4af2697575517e404cb079693df46..a74be885760e404ad3e38bd688c978246895d7ae 100644 --- a/frontend/src/metabase/admin/settings/components/SettingsEmailForm.jsx +++ b/frontend/src/metabase/admin/settings/components/SettingsEmailForm.jsx @@ -31,12 +31,19 @@ export default class SettingsEmailForm extends Component { componentWillMount() { // this gives us an opportunity to load up our formData with any existing values for elements + this.updateFormData(this.props); + } + + componentWillReceiveProps(nextProps) { + this.updateFormData(nextProps); + } + + updateFormData(props) { let formData = {}; - this.props.elements.forEach(function(element) { + for (const element of props.elements) { formData[element.key] = element.value; - }); - - this.setState({formData}); + } + this.setState({ formData }); } componentDidMount() { diff --git a/frontend/src/metabase/admin/settings/components/SettingsSetting.jsx b/frontend/src/metabase/admin/settings/components/SettingsSetting.jsx index 8e5498b759b80f4d52b3b19588a7084c33d1ccd3..998b5a826fcb82b286a77f46a6fc00e46c739f4c 100644 --- a/frontend/src/metabase/admin/settings/components/SettingsSetting.jsx +++ b/frontend/src/metabase/admin/settings/components/SettingsSetting.jsx @@ -45,6 +45,9 @@ export default class SettingsSetting extends Component { { errorMessage && <div className="text-error text-bold pt1">{errorMessage}</div> } + { setting.warning && + <div className="text-gold text-bold pt1">{setting.warning}</div> + } </li> ); } diff --git a/frontend/src/metabase/admin/settings/selectors.js b/frontend/src/metabase/admin/settings/selectors.js index ae501e697ee409d3393db53d75673754fa40a973..1303d13df831c3ab32ca578b33f4285763d7cceb 100644 --- a/frontend/src/metabase/admin/settings/selectors.js +++ b/frontend/src/metabase/admin/settings/selectors.js @@ -95,7 +95,7 @@ const SECTIONS = [ display_name: "SMTP Security", description: null, type: "radio", - options: { none: "None", ssl: "SSL", tls: "TLS" }, + options: { none: "None", ssl: "SSL", tls: "TLS", starttls: "STARTTLS" }, defaultValue: 'none' }, { @@ -270,7 +270,15 @@ for (const section of SECTIONS) { section.slug = slugify(section.name); } -export const getSettings = state => state.settings.settings; +export const getSettings = createSelector( + state => state.settings.settings, + state => state.admin.settings.warnings, + (settings, warnings) => + settings.map(setting => warnings[setting.key] ? + { ...setting, warning: warnings[setting.key] } : + setting + ) +) export const getSettingValues = createSelector( getSettings, diff --git a/frontend/src/metabase/admin/settings/settings.js b/frontend/src/metabase/admin/settings/settings.js index fa9db3da3aeac235527426350ee8bb2fe171e1e3..cc63d4829c5fc1a0545b1fe181c2c951900b4cc2 100644 --- a/frontend/src/metabase/admin/settings/settings.js +++ b/frontend/src/metabase/admin/settings/settings.js @@ -1,12 +1,14 @@ -import { createThunkAction } from "metabase/lib/redux"; +import { createThunkAction, handleActions, combineReducers } from "metabase/lib/redux"; import { SettingsApi, EmailApi, SlackApi } from "metabase/services"; import { refreshSiteSettings } from "metabase/redux/settings"; -// initializeSettings -export const initializeSettings = createThunkAction("INITIALIZE_SETTINGS", function() { +// ACITON TYPES AND ACTION CREATORS + +export const INITIALIZE_SETTINGS = "metabase/admin/settings/INITIALIZE_SETTINGS"; +export const initializeSettings = createThunkAction(INITIALIZE_SETTINGS, function() { return async function(dispatch, getState) { try { await dispatch(refreshSiteSettings()); @@ -17,8 +19,8 @@ export const initializeSettings = createThunkAction("INITIALIZE_SETTINGS", funct }; }); -// updateSetting -export const updateSetting = createThunkAction("UPDATE_SETTING", function(setting) { +export const UPDATE_SETTING = "metabase/admin/settings/UPDATE_SETTING"; +export const updateSetting = createThunkAction(UPDATE_SETTING, function(setting) { return async function(dispatch, getState) { try { await SettingsApi.put(setting); @@ -30,12 +32,13 @@ export const updateSetting = createThunkAction("UPDATE_SETTING", function(settin }; }); -// updateEmailSettings -export const updateEmailSettings = createThunkAction("UPDATE_EMAIL_SETTINGS", function(settings) { +export const UPDATE_EMAIL_SETTINGS = "metabase/admin/settings/UPDATE_EMAIL_SETTINGS"; +export const updateEmailSettings = createThunkAction(UPDATE_EMAIL_SETTINGS, function(settings) { return async function(dispatch, getState) { try { - await EmailApi.updateSettings(settings); + const result = await EmailApi.updateSettings(settings); await dispatch(refreshSiteSettings()); + return result; } catch(error) { console.log("error updating email settings", settings, error); throw error; @@ -43,8 +46,8 @@ export const updateEmailSettings = createThunkAction("UPDATE_EMAIL_SETTINGS", fu }; }); -// sendTestEmail -export const sendTestEmail = createThunkAction("SEND_TEST_EMAIL", function() { +export const SEND_TEST_EMAIL = "metabase/admin/settings/SEND_TEST_EMAIL"; +export const sendTestEmail = createThunkAction(SEND_TEST_EMAIL, function() { return async function(dispatch, getState) { try { await EmailApi.sendTest(); @@ -55,8 +58,8 @@ export const sendTestEmail = createThunkAction("SEND_TEST_EMAIL", function() { }; }); -// updateSlackSettings -export const updateSlackSettings = createThunkAction("UPDATE_SLACK_SETTINGS", function(settings) { +export const UPDATE_SLACK_SETTINGS = "metabase/admin/settings/UPDATE_SLACK_SETTINGS"; +export const updateSlackSettings = createThunkAction(UPDATE_SLACK_SETTINGS, function(settings) { return async function(dispatch, getState) { try { await SlackApi.updateSettings(settings); @@ -68,8 +71,19 @@ export const updateSlackSettings = createThunkAction("UPDATE_SLACK_SETTINGS", fu }; }, {}); -export const reloadSettings = createThunkAction("RELOAD_SETTINGS", function() { +export const RELOAD_SETTINGS = "metabase/admin/settings/RELOAD_SETTINGS"; +export const reloadSettings = createThunkAction(RELOAD_SETTINGS, function() { return async function(dispatch, getState) { await dispatch(refreshSiteSettings()); } }); + +// REDUCERS + +export const warnings = handleActions({ + [UPDATE_EMAIL_SETTINGS]: { next: (state, { payload }) => payload["with-corrections"] } +}, {}); + +export default combineReducers({ + warnings +}); diff --git a/frontend/src/metabase/reducers-main.js b/frontend/src/metabase/reducers-main.js index f4b176268a108ea4e6c658c063b091db2aca6d8f..57c3faf8c3425649129f797c3722efbf60f8c803 100644 --- a/frontend/src/metabase/reducers-main.js +++ b/frontend/src/metabase/reducers-main.js @@ -7,7 +7,7 @@ import { combineReducers } from 'redux'; import commonReducers from "./reducers-common"; /* admin */ -import adminReducers from "./reducers-admin"; +import admin from "metabase/admin/admin"; /* setup */ import * as setup from "metabase/setup/reducers"; @@ -45,6 +45,5 @@ export default { reference, setup: combineReducers(setup), user: combineReducers(user), - - ...adminReducers + admin }; diff --git a/src/metabase/api/email.clj b/src/metabase/api/email.clj index e3452558f1a6064e179f7e120478142a2bef6c02..27f18663f986bab4e4efc3d99ba089503c1493ea 100644 --- a/src/metabase/api/email.clj +++ b/src/metabase/api/email.clj @@ -1,11 +1,15 @@ (ns metabase.api.email "/api/email endpoints" - (:require [clojure.tools.logging :as log] - [clojure.set :as set] - [compojure.core :refer [GET PUT DELETE POST]] + (:require [clojure + [data :as data] + [set :as set] + [string :as string]] + [clojure.tools.logging :as log] + [compojure.core :refer [POST PUT]] + [metabase + [config :as config] + [email :as email]] [metabase.api.common :refer :all] - [metabase.config :as config] - [metabase.email :as email] [metabase.models.setting :as setting] [metabase.util.schema :as su])) @@ -51,6 +55,16 @@ #".*" {:message "Sorry, something went wrong. Please try again."})))) +(defn humanize-email-corrections + "formats warnings when security settings are autocorrected" + [corrections] + (into {} + (mapv (fn [[k v]] + [k (format "%s was autocorrected to %s" + (name (mb-to-smtp-settings k)) + (string/upper-case v))]) + corrections))) + (defendpoint PUT "/" "Update multiple `Settings` values. You must be a superuser to do this." [:as {settings :body}] @@ -63,10 +77,15 @@ ;; in normal conditions, validate connection (email/test-smtp-connection smtp-settings) ;; for unit testing just respond with a success message - {:error :SUCCESS})] + {:error :SUCCESS}) + tested-settings (merge settings (select-keys response [:port :security])) + [_ corrections _] (data/diff settings tested-settings) + properly-named-corrections (set/rename-keys corrections (set/map-invert mb-to-smtp-settings)) + corrected-settings (merge email-settings properly-named-corrections)] (if (= :SUCCESS (:error response)) ;; test was good, save our settings - (setting/set-many! email-settings) + (assoc (setting/set-many! corrected-settings) + :with-corrections (humanize-email-corrections properly-named-corrections)) ;; test failed, return response message {:status 500 :body (humanize-error-messages response)}))) diff --git a/src/metabase/email.clj b/src/metabase/email.clj index 9731b50acdbdae802f760dda254ccf090546ef74..ef5a7dbbc4de044c74ad9db012e20ef7065393a8 100644 --- a/src/metabase/email.clj +++ b/src/metabase/email.clj @@ -15,11 +15,11 @@ (defsetting email-smtp-password "SMTP password.") (defsetting email-smtp-port "The port your SMTP server uses for outgoing emails.") (defsetting email-smtp-security - "SMTP secure connection protocol. (tls, ssl, or none)" + "SMTP secure connection protocol. (tls, ssl, starttls, or none)" :default "none" :setter (fn [new-value] (when-not (nil? new-value) - (assert (contains? #{"tls" "ssl" "none"} new-value))) + (assert (contains? #{"tls" "ssl" "none" "starttls"} new-value))) (setting/set-string! :email-smtp-security new-value))) ;; ## PUBLIC INTERFACE @@ -38,6 +38,8 @@ (merge m (case (keyword ssl-setting) :tls {:tls true} :ssl {:ssl true} + :starttls {:starttls.enable true + :starttls.required true} {}))) (defn- smtp-settings [] @@ -85,27 +87,19 @@ {:error :ERROR :message (.getMessage e)}))) - -(defn test-smtp-connection - "Test the connection to an SMTP server to determine if we can send emails. - - Takes in a dictionary of properties such as: - {:host \"localhost\" - :port 587 - :user \"bigbird\" - :pass \"luckyme\" - :sender \"foo@mycompany.com\" - :security \"tls\"}" +(defn- run-smtp-test + "tests an SMTP configuration by attempting to connect and authenticate + if an authenticated method is passed in :security." [{:keys [host port user pass sender security] :as details}] {:pre [(string? host) (integer? port)]} (try - (let [ssl? (= security "ssl") - proto (if ssl? "smtps" "smtp") + (let [ssl? (= security "ssl") + proto (if ssl? "smtps" "smtp") details (-> details (assoc :proto proto :connectiontimeout "1000" - :timeout "1000") + :timeout "4000") (add-ssl-settings security)) session (doto (Session/getInstance (make-props sender details)) (.setDebug false))] @@ -117,3 +111,42 @@ (log/error "Error testing SMTP connection:" (.getMessage e)) {:error :ERROR :message (.getMessage e)}))) + +(def ^:private email-security-order ["tls" "starttls" "ssl"]) + +(defn- guess-smtp-security + "Attempts to use each of the security methods in security order with the same set of credentials. + This is used only when the initial connection attempt fails, so it won't overwrite a functioning + configuration. If this uses something other than the provided method, a warning gets printed on + the config page" + [details] + (loop [[security-type & more-to-try] email-security-order] ;; make sure this is not lazy, or chunking + (when security-type ;; can cause some servers to block requests + (let [test-result (run-smtp-test (assoc details :security security-type))] + (if (not= :ERROR (:error test-result)) + (assoc test-result :security security-type) + (do + (Thread/sleep 500) ;; try not to get banned from outlook.com + (recur more-to-try))))))) + +(defn test-smtp-connection + "Test the connection to an SMTP server to determine if we can send emails. + + Takes in a dictionary of properties such as: + {:host \"localhost\" + :port 587 + :user \"bigbird\" + :pass \"luckyme\" + :sender \"foo@mycompany.com\" + :security \"tls\"}" + [details] + (let [inital-attempt (run-smtp-test details) + it-worked? (= :SUCCESS (:error inital-attempt)) + attempted-fix (if (not it-worked?) + (guess-smtp-security details)) + we-fixed-it? (= :SUCCESS (:error attempted-fix))] + (if it-worked? + inital-attempt + (if we-fixed-it? + attempted-fix + inital-attempt))))