From 3be8056c610a49aae79daf6519f730c67be2c988 Mon Sep 17 00:00:00 2001 From: sadakchap Date: Thu, 2 Apr 2026 17:41:13 +0530 Subject: [PATCH] add email verification & reset notification --- src/components/Sidebar/B4aSidebar.react.js | 38 +- src/components/Sidebar/B4aSidebar.scss | 38 ++ src/dashboard/Dashboard.js | 7 + src/dashboard/DashboardView.react.js | 39 +- .../Notification/EmailLabelSettings.react.js | 18 + .../Notification/EmailPasswordReset.react.js | 445 ++++++++++++++ src/dashboard/Notification/EmailSettings.scss | 91 +++ .../Notification/EmailVerification.react.js | 558 ++++++++++++++++++ .../Push/PushAudiencesIndex.react.js | 2 +- src/dashboard/Push/PushDetails.react.js | 2 +- src/dashboard/Push/PushIndex.react.js | 2 +- src/dashboard/Push/PushNew.react.js | 2 +- src/lib/ParseApp.js | 29 + src/lib/serverInfo.js | 2 + 14 files changed, 1250 insertions(+), 23 deletions(-) create mode 100644 src/dashboard/Notification/EmailLabelSettings.react.js create mode 100644 src/dashboard/Notification/EmailPasswordReset.react.js create mode 100644 src/dashboard/Notification/EmailSettings.scss create mode 100644 src/dashboard/Notification/EmailVerification.react.js 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. +
+ )} + +
+ {showTemplateUpgradeCta && ( + + } + input={ +
+ +
+ } + theme={Field.Theme.BLUE} + /> + )} + + } + input={ +
+ trackSetField('passwordResetEmailSubject', getInputValue(valueOrEvent))} + disabled={!canEditFields} + /> +
+ } + textAlign="right" + theme={Field.Theme.BLUE} + /> + + } + input={ +
+ trackSetField('passwordResetEmailBody', getInputValue(valueOrEvent))} + disabled={!canEditFields} + /> +
+ } + textAlign="right" + theme={Field.Theme.BLUE} + /> +
+
+ ); + } + + 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={() => ( +
+ ); + } + + 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 = ( + +