diff --git a/src/components/Sidebar/B4aSidebar.react.js b/src/components/Sidebar/B4aSidebar.react.js
index 49f7e3e820..4731c34661 100644
--- a/src/components/Sidebar/B4aSidebar.react.js
+++ b/src/components/Sidebar/B4aSidebar.react.js
@@ -19,6 +19,7 @@ import AppsMenu from 'components/Sidebar/AppsMenu.react';
import AppName from 'components/Sidebar/AppName.react';
import { CurrentApp } from 'context/currentApp';
import { canAccess } from 'lib/serverInfo';
+import { Link } from 'react-router-dom';
// let isSidebarFixed = !isMobile();
let isSidebarCollapsed = undefined;
@@ -118,9 +119,42 @@ const B4aSidebar = ({
}
return (
- {subsections.map(({name, link, badge}) => {
+ {subsections.map(({ name, link, badge, children: groupChildren }) => {
+ if (groupChildren) {
+ const isGroupActive = subsection === name || groupChildren.some(c => c.name === subsection);
+ const groupLink = link.startsWith('/') ? prefix + link : link;
+ return (
+
+
+ {name}
+
+
+ {groupChildren.map(child => {
+ const childActive = subsection === child.name;
+ const childLink = child.link.startsWith('/') ? prefix + child.link : child.link;
+ return (
+
+ {childActive ? children : null}
+
+ );
+ })}
+
+
+ );
+ }
+
const active = subsection === name;
- // If link points to another component, adds the prefix
link = link.startsWith('/') ? prefix + link : link;
return (
div {
+ margin-bottom: 0.5rem;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+}
+
.action {
@include InterFont;
position: absolute;
diff --git a/src/dashboard/Dashboard.js b/src/dashboard/Dashboard.js
index 979ce69a10..7e7d9f70b5 100644
--- a/src/dashboard/Dashboard.js
+++ b/src/dashboard/Dashboard.js
@@ -79,6 +79,8 @@ const LazyCloudCode = lazy(() => import('./Data/CloudCode/B4ACloudCode.react'));
const LazyAppPlan = lazy(() => import('./AppPlan/AppPlan.react'));
const LazyAppSecurityReport = lazy(() => import('./AppSecurityReport/AppSecurityReport.react'));
const LazyDatabaseProfile = lazy(() => import('./DatabaseProfiler/DatabaseProfiler.react'));
+const LazyEmailVerification = lazy(() => import('./Notification/EmailVerification.react'));
+const LazyEmailPasswordReset = lazy(() => import('./Notification/EmailPasswordReset.react'));
async function fetchHubUser() {
try {
@@ -487,6 +489,11 @@ class Dashboard extends React.Component {
{/* } /> */}
+ } />
+ } />
+ } />
+ } />
+
} />
} />
diff --git a/src/dashboard/DashboardView.react.js b/src/dashboard/DashboardView.react.js
index 79b6376731..18d9e541b4 100644
--- a/src/dashboard/DashboardView.react.js
+++ b/src/dashboard/DashboardView.react.js
@@ -334,26 +334,31 @@ export default class DashboardView extends React.Component {
subsections: apiSubSections
});
- const pushSubSections = [];
-
- pushSubSections.push({
- name: 'Send New Push',
- link: '/push/new',
- });
- pushSubSections.push({
- name: 'Past Pushes',
- link: '/push/activity',
- });
- pushSubSections.push({
- name: 'Audiences',
- link: '/push/audiences',
- });
+ const notificationSubSections = [
+ {
+ name: 'Email',
+ link: '/notification/email',
+ children: [
+ { name: 'Verification', link: '/notification/email/verification' },
+ { name: 'Password Reset', link: '/notification/email/password-reset' },
+ ],
+ },
+ {
+ name: 'Pushes',
+ link: '/push/new',
+ children: [
+ { name: 'Send New Push', link: '/push/new' },
+ { name: 'Past Pushes', link: '/push/activity' },
+ { name: 'Audiences', link: '/push/audiences' },
+ ],
+ },
+ ];
appSidebarSections.push({
- name: 'Push Notifications',
+ name: 'Notification',
icon: 'b4a-push-notification-icon',
- link: '/push',
- subsections: pushSubSections
+ link: '/notification',
+ subsections: notificationSubSections,
});
appSidebarSections.push({
diff --git a/src/dashboard/Notification/EmailLabelSettings.react.js b/src/dashboard/Notification/EmailLabelSettings.react.js
new file mode 100644
index 0000000000..225d501340
--- /dev/null
+++ b/src/dashboard/Notification/EmailLabelSettings.react.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import BaseLabelSettings from 'components/LabelSettings/LabelSettings.react';
+import styles from './EmailSettings.scss';
+
+export default function EmailLabelSettings({ description, ...props }) {
+ const leftAlignedText = props.text ? {props.text} : props.text;
+ const leftAlignedDescription = description
+ ? {description}
+ : description;
+
+ return (
+
+ );
+}
diff --git a/src/dashboard/Notification/EmailPasswordReset.react.js b/src/dashboard/Notification/EmailPasswordReset.react.js
new file mode 100644
index 0000000000..55aa712473
--- /dev/null
+++ b/src/dashboard/Notification/EmailPasswordReset.react.js
@@ -0,0 +1,445 @@
+import React from 'react';
+import { withRouter } from 'lib/withRouter';
+import Toolbar from 'components/Toolbar/Toolbar.react';
+import DashboardView from 'dashboard/DashboardView.react';
+import B4aLoaderContainer from 'components/B4aLoaderContainer/B4aLoaderContainer.react';
+import FlowView from 'components/FlowView/FlowView.react';
+import Field from 'components/Field/Field.react';
+import Fieldset from 'components/Fieldset/Fieldset.react';
+import Label from 'components/Label/Label.react';
+import TextInputSettings from 'components/TextInputSettings/TextInputSettings.react';
+import EmailLabelSettings from 'dashboard/Notification/EmailLabelSettings.react';
+import styles from './EmailSettings.scss';
+import EmptyGhostState from 'components/EmptyGhostState/EmptyGhostState.react';
+import joinWithFinal from 'lib/joinWithFinal';
+import B4aModal from 'components/B4aModal/B4aModal.react';
+import Button from 'components/Button/Button.react';
+import { Link } from 'react-router-dom';
+
+const DEFAULT_FIELDS = {
+ passwordResetEmailSubject: 'Password Reset Request for *|appname|*',
+ passwordResetEmailBody:
+ 'Hi,\n\n' +
+ 'You requested a password reset for *|appname|*.\n\n' +
+ 'Click here to reset it:\n' +
+ '*|link|*',
+};
+
+const getLoadErrorMessage = (err, fallbackMessage) => {
+ if (typeof err === 'string' && err.trim()) {
+ return err;
+ }
+
+ const responseData = err?.response?.data;
+ if (typeof responseData?.error === 'string' && responseData.error.trim()) {
+ return responseData.error;
+ }
+ if (typeof responseData?.message === 'string' && responseData.message.trim()) {
+ return responseData.message;
+ }
+ if (typeof responseData === 'string' && responseData.trim()) {
+ return responseData;
+ }
+ if (typeof err?.message === 'string' && err.message.trim()) {
+ return err.message;
+ }
+ if (err?.response?.status === 404) {
+ return 'Email settings endpoint not found (404).';
+ }
+
+ return fallbackMessage;
+};
+
+const formatChangeValue = value => {
+ if (typeof value === 'boolean') {
+ return value ? 'enabled' : 'disabled';
+ }
+
+ if (value === null || value === undefined || value === '') {
+ return '(empty)';
+ }
+
+ const serialized =
+ typeof value === 'object'
+ ? JSON.stringify(value)
+ : String(value);
+ const normalized = serialized.replace(/\s+/g, ' ').trim();
+ return normalized.length > 60 ? `${normalized.slice(0, 57)}...` : normalized;
+};
+
+const renderChangedValuesFooter = (changes, fieldOptions) => {
+ const entries = Object.keys(changes)
+ .filter(key => fieldOptions[key])
+ .map(key => (
+
+ {fieldOptions[key].friendlyName} to {formatChangeValue(changes[key])}
+
+ ));
+
+ if (entries.length === 0) {
+ return null;
+ }
+
+ return (
+
+ You've changed {joinWithFinal(null, entries, ', ', ' and ')}.
+
+ );
+};
+
+@withRouter
+class EmailPasswordReset extends DashboardView {
+ constructor() {
+ super();
+ this.section = 'Notification';
+ this.subsection = 'Password Reset';
+ this.state = {
+ isLoading: true,
+ initialFields: { ...DEFAULT_FIELDS },
+ canEditAllProperties: false,
+ canChangeEmailTemplate: false,
+ hasPermission: true,
+ isUserVerified: false,
+ errorMessage: null,
+ allEmailSettings: null,
+ preventLoginWithUnverifiedEmail: false,
+ isDirty: false,
+ modal: null,
+ };
+
+ this.unblock = null;
+ this.onBeforeUnload = null;
+ this.flowViewRef = React.createRef();
+ }
+
+ componentDidMount() {
+ this.loadEmailSettings();
+
+ if (this.props.navigator && typeof this.props.navigator.block === 'function') {
+ this.unblock = this.props.navigator.block(tx => {
+ if (this.state.isDirty) {
+ const unblock = this.unblock && this.unblock.bind(this);
+ const autoUnblockingTx = {
+ ...tx,
+ retry() {
+ if (unblock) {
+ unblock();
+ }
+ tx.retry();
+ },
+ };
+
+ const modal = (
+
+
+ }
+ />
+ );
+ this.setState({ modal });
+ } else {
+ if (this.unblock) {
+ this.unblock();
+ }
+ tx.retry();
+ }
+ });
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ const wasDirty = !!prevState?.isDirty;
+ const isDirty = !!this.state.isDirty;
+
+ if (!wasDirty && isDirty) {
+ if (!this.onBeforeUnload) {
+ this.onBeforeUnload = (e) => {
+ e.preventDefault();
+ e.returnValue = '';
+ return '';
+ };
+ }
+ window.addEventListener('beforeunload', this.onBeforeUnload);
+ } else if (wasDirty && !isDirty) {
+ window.removeEventListener('beforeunload', this.onBeforeUnload);
+ }
+ }
+
+ componentWillUnmount() {
+ if (this.unblock) {
+ this.unblock();
+ }
+ window.removeEventListener('beforeunload', this.onBeforeUnload);
+ }
+
+ async loadEmailSettings() {
+ try {
+ const data = await this.context.getEmailSettings();
+ const { emailSettings, preventLoginWithUnverifiedEmail, canChangeEmailTemplate, featuresPermission, isPaidPlan, userVerification } = data;
+
+ const isUserVerified = isPaidPlan || (
+ userVerification.emailVerified &&
+ userVerification.phoneNumberVerified &&
+ userVerification.cardValidation
+ );
+ const hasPermission = !featuresPermission || featuresPermission.verificationEmails === 'Write';
+ const canEditAllProperties = !!(emailSettings && emailSettings.canEditAllProperties);
+
+ const initialFields = {
+ passwordResetEmailSubject: emailSettings?.passwordResetEmailSubject || DEFAULT_FIELDS.passwordResetEmailSubject,
+ passwordResetEmailBody: emailSettings?.passwordResetEmailBody || DEFAULT_FIELDS.passwordResetEmailBody,
+ };
+
+ this.setState({
+ isLoading: false,
+ initialFields,
+ canEditAllProperties,
+ canChangeEmailTemplate: !!canChangeEmailTemplate,
+ hasPermission,
+ isUserVerified,
+ errorMessage: null,
+ allEmailSettings: emailSettings,
+ preventLoginWithUnverifiedEmail: preventLoginWithUnverifiedEmail || false,
+ });
+ } catch (err) {
+ this.setState({
+ isLoading: false,
+ errorMessage: getLoadErrorMessage(err, 'Failed to load email settings'),
+ });
+ }
+ }
+
+ renderForm({ fields, setField }) {
+ const { canChangeEmailTemplate, isUserVerified, hasPermission } = this.state;
+ const canEditFields = isUserVerified && hasPermission && canChangeEmailTemplate;
+ const showTemplateUpgradeCta = isUserVerified && hasPermission && !canEditFields;
+
+ const trackSetField = (key, value) => {
+ setField(key, value);
+ if (!this.state.isDirty) {
+ this.setState({ isDirty: true });
+ }
+ };
+ const getInputValue = valueOrEvent => (
+ valueOrEvent && valueOrEvent.target ? valueOrEvent.target.value : valueOrEvent
+ );
+
+ return (
+
+ {!isUserVerified && (
+
+ Please verify your account to manage email settings.
+
+ )}
+
+ {!hasPermission && (
+
+ You do not have permission to edit email settings.
+
+ )}
+
+
+
+ );
+ }
+
+ renderContent() {
+ const toolbar = (
+
+ );
+ const { isLoading, initialFields, errorMessage, hasPermission, isUserVerified } = this.state;
+
+ const fieldsOptions = {
+ passwordResetEmailSubject: { friendlyName: 'password reset email subject' },
+ passwordResetEmailBody: { friendlyName: 'password reset email body' },
+ };
+
+ let content = null;
+ if (errorMessage) {
+ content = (
+
+ {
+ this.setState({ isLoading: true, errorMessage: null });
+ this.loadEmailSettings();
+ }}
+ />
+
+ );
+ } else if (!isLoading) {
+ const validateForm = ({ fields }) => {
+ const passwordResetEmailBody = fields.passwordResetEmailBody || '';
+ if (passwordResetEmailBody && !passwordResetEmailBody.includes('*|link|*')) {
+ return 'Password reset email body must include *|link|* placeholder.';
+ }
+ return '';
+ };
+
+ content = (
+
+ {
+ const { allEmailSettings, preventLoginWithUnverifiedEmail } = this.state;
+ const emailSettings = {
+ replyTo: allEmailSettings?.replyTo,
+ displayName: allEmailSettings?.displayName,
+ verificationEmailEnaled: allEmailSettings?.verificationEmailEnaled,
+ verificationEmailSubject: allEmailSettings?.verificationEmailSubject,
+ verificationEmailBody: allEmailSettings?.verificationEmailBody,
+ passwordResetEmailSubject: fields.passwordResetEmailSubject,
+ passwordResetEmailBody: fields.passwordResetEmailBody,
+ };
+ return this.context.updateEmailSettings(emailSettings, preventLoginWithUnverifiedEmail);
+ }}
+ afterSave={({ fields, resetFields }) => {
+ this.setState({
+ initialFields: { ...fields },
+ isDirty: false,
+ allEmailSettings: {
+ ...this.state.allEmailSettings,
+ passwordResetEmailSubject: fields.passwordResetEmailSubject,
+ passwordResetEmailBody: fields.passwordResetEmailBody,
+ },
+ });
+ setTimeout(() => resetFields(), 1200);
+ }}
+ secondaryButton={() => (
+ {
+ this.setState({ isDirty: false });
+ if (this.flowViewRef.current) {
+ this.flowViewRef.current.resetFields();
+ }
+ }}
+ color="white"
+ dark={true}
+ value="Cancel"
+ />
+ )}
+ footerContents={({ changes }) => {
+ if (!hasPermission || !isUserVerified) {
+ return null;
+ }
+ return renderChangedValuesFooter(changes, fieldsOptions);
+ }}
+ validate={validateForm}
+ renderForm={this.renderForm.bind(this)}
+ />
+
+ );
+ }
+
+ return (
+
+
+ {content}
+
+ {toolbar}
+ {this.state.modal}
+
+ );
+ }
+}
+
+export default EmailPasswordReset;
diff --git a/src/dashboard/Notification/EmailSettings.scss b/src/dashboard/Notification/EmailSettings.scss
new file mode 100644
index 0000000000..67d2492c35
--- /dev/null
+++ b/src/dashboard/Notification/EmailSettings.scss
@@ -0,0 +1,91 @@
+@import 'stylesheets/globals.scss';
+@import 'stylesheets/back4app.scss';
+
+.content {
+ @include SoraFont;
+ position: relative;
+ min-height: calc(#{$content-max-height} - #{$toolbar-height});
+ margin-top: $toolbar-height;
+ background: $dark;
+ color: $white;
+ & .mainContent {
+ height: calc(#{$content-max-height} - #{$toolbar-height});
+ position: relative;
+ overflow: auto;
+ }
+}
+
+// Same width constraints as General Settings (.generalSettingsWrapper in Settings.scss)
+.emailSettingsWrapper {
+ width: 100%;
+ padding: 0;
+ max-width: 870px;
+ margin: 0 auto;
+ margin-top: 3rem;
+}
+
+@media only screen and (max-width: 1200px) {
+ .emailSettingsWrapper {
+ padding: 0 2rem;
+ }
+}
+
+// Label info icon (same pattern as Custom Parse Options / Custom Pages section)
+.infoIcon {
+ cursor: help;
+ vertical-align: middle;
+}
+
+.infoHelp {
+ display: inline-flex;
+ align-items: center;
+ line-height: 1;
+}
+
+.labelTextLeft,
+.labelDescriptionLeft {
+ display: block;
+ width: 100%;
+ text-align: left;
+}
+
+// TextInputSettings: 16px padding except right (0) so scrollbar sits on the bordered edge
+.emailTextField {
+ width: 100%;
+ min-width: 0;
+ padding: 10px 16px;
+
+ > div {
+ display: flex !important;
+ justify-content: stretch !important;
+ width: 100% !important;
+ margin-top: 0 !important;
+ padding: 0 !important;
+ }
+
+ textarea,
+ input {
+ width: 100% !important;
+ max-width: 100% !important;
+ margin: 0 !important;
+ padding: 6px !important;
+ border: none !important;
+ outline: none !important;
+ box-shadow: none !important;
+ box-sizing: border-box;
+ }
+}
+
+.emailTextFieldRight {
+ input {
+ text-align: right !important;
+ }
+}
+
+.emailSwitchField {
+ display: flex;
+ justify-content: flex-end;
+ width: 100%;
+ padding: 10px 16px;
+ box-sizing: border-box;
+}
diff --git a/src/dashboard/Notification/EmailVerification.react.js b/src/dashboard/Notification/EmailVerification.react.js
new file mode 100644
index 0000000000..28afd03efc
--- /dev/null
+++ b/src/dashboard/Notification/EmailVerification.react.js
@@ -0,0 +1,558 @@
+import React from 'react';
+import { withRouter } from 'lib/withRouter';
+import Toolbar from 'components/Toolbar/Toolbar.react';
+import DashboardView from 'dashboard/DashboardView.react';
+import B4aLoaderContainer from 'components/B4aLoaderContainer/B4aLoaderContainer.react';
+import FlowView from 'components/FlowView/FlowView.react';
+import Field from 'components/Field/Field.react';
+import Fieldset from 'components/Fieldset/Fieldset.react';
+import Label from 'components/Label/Label.react';
+import TextInputSettings from 'components/TextInputSettings/TextInputSettings.react';
+import B4aToggle from 'components/Toggle/B4aToggle.react';
+import EmailLabelSettings from 'dashboard/Notification/EmailLabelSettings.react';
+import styles from './EmailSettings.scss';
+import EmptyGhostState from 'components/EmptyGhostState/EmptyGhostState.react';
+import joinWithFinal from 'lib/joinWithFinal';
+import B4aModal from 'components/B4aModal/B4aModal.react';
+import Button from 'components/Button/Button.react';
+import { Link } from 'react-router-dom';
+
+const DEFAULT_VERIFICATION_BODY =
+ 'Hi,\n\n' +
+ 'You are being asked to confirm the e-mail address *|email|* with *|appname|*\n\n' +
+ 'Click here to confirm it:\n' +
+ '*|link|*';
+
+const DEFAULT_FIELDS = {
+ verificationEmailEnaled: false,
+ preventLoginWithUnverifiedEmail: false,
+ replyTo: 'no-reply@b4a.app',
+ displayName: '',
+ verificationEmailSubject: 'Please verify your e-mail for *|appname|*',
+ verificationEmailBody: DEFAULT_VERIFICATION_BODY,
+};
+
+const getLoadErrorMessage = (err, fallbackMessage) => {
+ if (typeof err === 'string' && err.trim()) {
+ return err;
+ }
+
+ const responseData = err?.response?.data;
+ if (typeof responseData?.error === 'string' && responseData.error.trim()) {
+ return responseData.error;
+ }
+ if (typeof responseData?.message === 'string' && responseData.message.trim()) {
+ return responseData.message;
+ }
+ if (typeof responseData === 'string' && responseData.trim()) {
+ return responseData;
+ }
+ if (typeof err?.message === 'string' && err.message.trim()) {
+ return err.message;
+ }
+ if (err?.response?.status === 404) {
+ return 'Email settings endpoint not found (404).';
+ }
+
+ return fallbackMessage;
+};
+
+const formatChangeValue = value => {
+ if (typeof value === 'boolean') {
+ return value ? 'enabled' : 'disabled';
+ }
+
+ if (value === null || value === undefined || value === '') {
+ return '(empty)';
+ }
+
+ const serialized =
+ typeof value === 'object'
+ ? JSON.stringify(value)
+ : String(value);
+ const normalized = serialized.replace(/\s+/g, ' ').trim();
+ return normalized.length > 60 ? `${normalized.slice(0, 57)}...` : normalized;
+};
+
+const renderChangedValuesFooter = (changes, fieldOptions) => {
+ const entries = Object.keys(changes)
+ .filter(key => fieldOptions[key])
+ .map(key => (
+
+ {fieldOptions[key].friendlyName} to {formatChangeValue(changes[key])}
+
+ ));
+
+ if (entries.length === 0) {
+ return null;
+ }
+
+ return (
+
+ You've changed {joinWithFinal(null, entries, ', ', ' and ')}.
+
+ );
+};
+
+@withRouter
+class EmailVerification extends DashboardView {
+ constructor() {
+ super();
+ this.section = 'Notification';
+ this.subsection = 'Verification';
+ this.state = {
+ isLoading: true,
+ initialFields: { ...DEFAULT_FIELDS },
+ canEditAllProperties: false,
+ canChangeEmailTemplate: false,
+ hasPermission: true,
+ isUserVerified: false,
+ alertValidationCreditCard: false,
+ errorMessage: null,
+ allEmailSettings: null,
+ isDirty: false,
+ modal: null,
+ };
+
+ this.unblock = null;
+ this.onBeforeUnload = null;
+ this.flowViewRef = React.createRef();
+ }
+
+ componentDidMount() {
+ this.loadEmailSettings();
+
+ if (this.props.navigator && typeof this.props.navigator.block === 'function') {
+ this.unblock = this.props.navigator.block(tx => {
+ if (this.state.isDirty) {
+ const unblock = this.unblock && this.unblock.bind(this);
+ const autoUnblockingTx = {
+ ...tx,
+ retry() {
+ if (unblock) {
+ unblock();
+ }
+ tx.retry();
+ },
+ };
+
+ const modal = (
+
+ this.setState({ modal: null })}
+ />
+ {
+ this.setState({ modal: null, isDirty: false });
+ autoUnblockingTx.retry();
+ }}
+ />
+
+ }
+ />
+ );
+ this.setState({ modal });
+ } else {
+ if (this.unblock) {
+ this.unblock();
+ }
+ tx.retry();
+ }
+ });
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ const wasDirty = !!prevState?.isDirty;
+ const isDirty = !!this.state.isDirty;
+
+ if (!wasDirty && isDirty) {
+ if (!this.onBeforeUnload) {
+ this.onBeforeUnload = (e) => {
+ e.preventDefault();
+ e.returnValue = '';
+ return '';
+ };
+ }
+ window.addEventListener('beforeunload', this.onBeforeUnload);
+ } else if (wasDirty && !isDirty) {
+ window.removeEventListener('beforeunload', this.onBeforeUnload);
+ }
+ }
+
+ componentWillUnmount() {
+ if (this.unblock) {
+ this.unblock();
+ }
+ window.removeEventListener('beforeunload', this.onBeforeUnload);
+ }
+
+ async loadEmailSettings() {
+ try {
+ const data = await this.context.getEmailSettings();
+ const { emailSettings, preventLoginWithUnverifiedEmail, canChangeEmailTemplate, featuresPermission, isPaidPlan, userVerification } = data;
+
+ const isUserVerified = isPaidPlan || (
+ userVerification.emailVerified &&
+ userVerification.phoneNumberVerified &&
+ userVerification.cardValidation
+ );
+ const alertValidationCreditCard = !isPaidPlan && (!userVerification || !userVerification.cardValidation);
+ const hasPermission = !featuresPermission || featuresPermission.verificationEmails === 'Write';
+ const canEditAllProperties = !!(emailSettings && emailSettings.canEditAllProperties);
+
+ const initialFields = {
+ verificationEmailEnaled: emailSettings?.verificationEmailEnaled || false,
+ preventLoginWithUnverifiedEmail: preventLoginWithUnverifiedEmail || false,
+ replyTo: emailSettings?.replyTo || 'no-reply@b4a.app',
+ displayName: emailSettings?.displayName || '',
+ verificationEmailSubject: emailSettings?.verificationEmailSubject || DEFAULT_FIELDS.verificationEmailSubject,
+ verificationEmailBody: emailSettings?.verificationEmailBody || DEFAULT_FIELDS.verificationEmailBody,
+ };
+
+ this.setState({
+ isLoading: false,
+ initialFields,
+ canEditAllProperties,
+ canChangeEmailTemplate: !!canChangeEmailTemplate,
+ hasPermission,
+ isUserVerified,
+ alertValidationCreditCard,
+ errorMessage: null,
+ allEmailSettings: emailSettings,
+ });
+ } catch (err) {
+ this.setState({
+ isLoading: false,
+ errorMessage: getLoadErrorMessage(err, 'Failed to load email settings'),
+ });
+ }
+ }
+
+ renderForm({ fields, setField }) {
+ const { canChangeEmailTemplate, isUserVerified, hasPermission } = this.state;
+ const canEditBasicFields = isUserVerified && hasPermission;
+ const canEditTemplateFields = canEditBasicFields && canChangeEmailTemplate;
+ const showTemplateUpgradeCta = isUserVerified && hasPermission && !canEditTemplateFields;
+
+ const trackSetField = (key, value) => {
+ setField(key, value);
+ if (!this.state.isDirty) {
+ this.setState({ isDirty: true });
+ }
+ };
+ const getInputValue = valueOrEvent => (
+ valueOrEvent && valueOrEvent.target ? valueOrEvent.target.value : valueOrEvent
+ );
+
+ return (
+
+ {!isUserVerified && (
+
+ Please verify your account to manage email settings.
+
+ )}
+
+ {!hasPermission && (
+
+ You do not have permission to edit email verification settings.
+
+ )}
+
+
+
+ );
+ }
+
+ renderContent() {
+ const toolbar = (
+
+ );
+ const { isLoading, initialFields, errorMessage, hasPermission, isUserVerified } = this.state;
+
+ const fieldsOptions = {
+ verificationEmailEnaled: { friendlyName: 'enable verification emails', showTo: true },
+ preventLoginWithUnverifiedEmail: { friendlyName: 'prevent login with unverified email', showTo: true },
+ replyTo: { friendlyName: 'reply to address' },
+ displayName: { friendlyName: 'display name' },
+ verificationEmailSubject: { friendlyName: 'verification email subject' },
+ verificationEmailBody: { friendlyName: 'verification email body' },
+ };
+
+ let content = null;
+ if (errorMessage) {
+ content = (
+
+ {
+ this.setState({ isLoading: true, errorMessage: null });
+ this.loadEmailSettings();
+ }}
+ />
+
+ );
+ } else if (!isLoading) {
+ const validateForm = ({ fields }) => {
+ const verificationEmailBody = fields.verificationEmailBody || '';
+ if (verificationEmailBody && !verificationEmailBody.includes('*|link|*')) {
+ return 'Verification email body must include *|link|* placeholder.';
+ }
+ return '';
+ };
+
+ content = (
+
+ {
+ const { allEmailSettings } = this.state;
+ const emailSettings = {
+ replyTo: fields.replyTo,
+ displayName: fields.displayName,
+ verificationEmailEnaled: fields.verificationEmailEnaled,
+ verificationEmailSubject: fields.verificationEmailSubject,
+ verificationEmailBody: fields.verificationEmailBody,
+ passwordResetEmailSubject: allEmailSettings?.passwordResetEmailSubject,
+ passwordResetEmailBody: allEmailSettings?.passwordResetEmailBody,
+ };
+ const preventLoginWithUnverifiedEmail = fields.verificationEmailEnaled
+ ? fields.preventLoginWithUnverifiedEmail
+ : false;
+ return this.context.updateEmailSettings(emailSettings, preventLoginWithUnverifiedEmail);
+ }}
+ afterSave={({ fields, resetFields }) => {
+ this.setState({
+ initialFields: { ...fields },
+ isDirty: false,
+ allEmailSettings: {
+ ...this.state.allEmailSettings,
+ replyTo: fields.replyTo,
+ displayName: fields.displayName,
+ verificationEmailEnaled: fields.verificationEmailEnaled,
+ verificationEmailSubject: fields.verificationEmailSubject,
+ verificationEmailBody: fields.verificationEmailBody,
+ },
+ });
+ setTimeout(() => resetFields(), 1200);
+ }}
+ secondaryButton={() => (
+ {
+ this.setState({ isDirty: false });
+ if (this.flowViewRef.current) {
+ this.flowViewRef.current.resetFields();
+ }
+ }}
+ color="white"
+ dark={true}
+ value="Cancel"
+ />
+ )}
+ footerContents={({ changes }) => {
+ if (!hasPermission || !isUserVerified) {
+ return null;
+ }
+ return renderChangedValuesFooter(changes, fieldsOptions);
+ }}
+ validate={validateForm}
+ renderForm={this.renderForm.bind(this)}
+ />
+
+ );
+ }
+
+ return (
+
+
+ {content}
+
+ {toolbar}
+ {this.state.modal}
+
+ );
+ }
+}
+
+export default EmailVerification;
diff --git a/src/dashboard/Push/PushAudiencesIndex.react.js b/src/dashboard/Push/PushAudiencesIndex.react.js
index 8f02ac5fe4..e0969e1533 100644
--- a/src/dashboard/Push/PushAudiencesIndex.react.js
+++ b/src/dashboard/Push/PushAudiencesIndex.react.js
@@ -38,7 +38,7 @@ const XHR_KEY = 'PushAudiencesIndex';
class PushAudiencesIndex extends DashboardView {
constructor() {
super();
- this.section = 'Push Notifications';
+ this.section = 'Notification';
this.subsection = 'Audiences';
this.state = {
availableDevices: [],
diff --git a/src/dashboard/Push/PushDetails.react.js b/src/dashboard/Push/PushDetails.react.js
index 274870f081..51fad95f4d 100644
--- a/src/dashboard/Push/PushDetails.react.js
+++ b/src/dashboard/Push/PushDetails.react.js
@@ -231,7 +231,7 @@ const DROPDOWN_KEY_GROUP_B = 'Group B';
class PushDetails extends DashboardView {
constructor() {
super();
- this.section = 'Push Notifications';
+ this.section = 'Notification';
this.subsection = '';
this.state = {
pushDetails: {},
diff --git a/src/dashboard/Push/PushIndex.react.js b/src/dashboard/Push/PushIndex.react.js
index 03266d7c27..9e6d5d2319 100644
--- a/src/dashboard/Push/PushIndex.react.js
+++ b/src/dashboard/Push/PushIndex.react.js
@@ -228,7 +228,7 @@ const getPushTime = (pushTime, updatedAt) => {
class PushIndex extends DashboardView {
constructor() {
super();
- this.section = 'Push Notifications';
+ this.section = 'Notification';
this.subsection = 'Past Pushes';
this.action = new SidebarAction('Send a push', this.navigateToNew.bind(this));
this.state = {
diff --git a/src/dashboard/Push/PushNew.react.js b/src/dashboard/Push/PushNew.react.js
index b1b2033cfe..c755719178 100644
--- a/src/dashboard/Push/PushNew.react.js
+++ b/src/dashboard/Push/PushNew.react.js
@@ -156,7 +156,7 @@ class PushNew extends DashboardView {
constructor() {
super();
this.xhrs = [];
- this.section = 'Push Notifications';
+ this.section = 'Notification';
this.subsection = 'Send New Push';
this.state = {
pushAudiencesFetched: false,
diff --git a/src/lib/ParseApp.js b/src/lib/ParseApp.js
index 4dce048d17..03804ca913 100644
--- a/src/lib/ParseApp.js
+++ b/src/lib/ParseApp.js
@@ -1654,6 +1654,35 @@ export default class ParseApp {
}
}
+ async getEmailSettings() {
+ try {
+ return (
+ await axios.get(
+ // eslint-disable-next-line no-undef
+ `${b4aSettings.BACK4APP_API_PATH}/parse-app/${this.slug}/email`,
+ { withCredentials: true }
+ )
+ ).data;
+ } catch (err) {
+ throw err.response && err.response.data && err.response.data.error ? err.response.data.error : err;
+ }
+ }
+
+ async updateEmailSettings(emailSettings, preventLoginWithUnverifiedEmail) {
+ try {
+ return (
+ await axios.post(
+ // eslint-disable-next-line no-undef
+ `${b4aSettings.BACK4APP_API_PATH}/parse-app/${this.slug}/email`,
+ { emailSettings, preventLoginWithUnverifiedEmail },
+ { withCredentials: true }
+ )
+ ).data;
+ } catch (err) {
+ throw err.response && err.response.data && err.response.data.error ? err.response.data.error : err;
+ }
+ }
+
async fetchAvgResponseTime(limit = 60) {
try {
return (
diff --git a/src/lib/serverInfo.js b/src/lib/serverInfo.js
index 660cac8a51..4958d62955 100644
--- a/src/lib/serverInfo.js
+++ b/src/lib/serverInfo.js
@@ -26,6 +26,8 @@ export const ALWAYS_ALLOWED_ROUTES = [
'environment-variables',
'social-auth',
'Social Auth',
+ 'Notification',
+ 'notification',
];
export const canAccess = (serverInfo, route) => {