From 6b09e982b4dc8b15b6112d3dfb935d10dd2ca058 Mon Sep 17 00:00:00 2001 From: sadakchap Date: Tue, 14 Apr 2026 15:49:01 +0530 Subject: [PATCH 1/6] andriod push notification --- src/dashboard/Dashboard.js | 2 + src/dashboard/DashboardView.react.js | 1 + .../Push/PushAndroidSettings.react.js | 361 ++++++++++++++++++ src/dashboard/Push/PushAndroidSettings.scss | 130 +++++++ src/lib/ParseApp.js | 31 ++ src/lib/serverInfo.js | 2 - 6 files changed, 525 insertions(+), 2 deletions(-) create mode 100644 src/dashboard/Push/PushAndroidSettings.react.js create mode 100644 src/dashboard/Push/PushAndroidSettings.scss diff --git a/src/dashboard/Dashboard.js b/src/dashboard/Dashboard.js index 54a3d1f19..d2eaa2b7a 100644 --- a/src/dashboard/Dashboard.js +++ b/src/dashboard/Dashboard.js @@ -82,6 +82,7 @@ const LazyAppSecurityReport = lazy(() => import('./AppSecurityReport/AppSecurity const LazyDatabaseProfile = lazy(() => import('./DatabaseProfiler/DatabaseProfiler.react')); const LazyEmailVerification = lazy(() => import('./Notification/EmailVerification.react')); const LazyEmailPasswordReset = lazy(() => import('./Notification/EmailPasswordReset.react')); +const LazyPushAndroidSettings = lazy(() => import('./Push/PushAndroidSettings.react')); async function fetchHubUser() { @@ -504,6 +505,7 @@ class Dashboard extends React.Component { } /> } /> } /> + } /> } /> } /> diff --git a/src/dashboard/DashboardView.react.js b/src/dashboard/DashboardView.react.js index 1f673fd9d..f5b990a08 100644 --- a/src/dashboard/DashboardView.react.js +++ b/src/dashboard/DashboardView.react.js @@ -355,6 +355,7 @@ export default class DashboardView extends React.Component { { name: 'Send New Push', link: '/push/new' }, { name: 'Past Pushes', link: '/push/activity' }, { name: 'Audiences', link: '/push/audiences' }, + { name: 'Android', link: '/push/android-settings' }, ], }, ]; diff --git a/src/dashboard/Push/PushAndroidSettings.react.js b/src/dashboard/Push/PushAndroidSettings.react.js new file mode 100644 index 000000000..94c3b5ca1 --- /dev/null +++ b/src/dashboard/Push/PushAndroidSettings.react.js @@ -0,0 +1,361 @@ +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 Button from 'components/Button/Button.react'; +import FileInput from 'components/FileInput/FileInput.react'; +import EmptyGhostState from 'components/EmptyGhostState/EmptyGhostState.react'; +import styles from './PushAndroidSettings.scss'; + +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 err?.message === 'string' && err.message.trim()) { + return err.message; + } + return fallbackMessage; +}; + +@withRouter +class PushAndroidSettings extends DashboardView { + constructor() { + super(); + this.section = 'Notification'; + this.subsection = 'Android'; + this.state = { + isLoading: true, + loadingError: null, + pushType: null, + projectId: null, + senderId: null, + apiKey: null, + appName: null, + hasPermission: true, + selectedFile: null, + fileError: null, + }; + this.flowSetField = null; + this.flowViewRef = React.createRef(); + } + + componentDidMount() { + this._isMounted = true; + this.loadConfig(); + } + + componentWillUnmount() { + this._isMounted = false; + } + + async loadConfig() { + try { + const data = await this.context.getPushAndroidConfig(); + const hasPermission = !data.featuresPermission || + data.featuresPermission.pushAndroidSettings === 'Write'; + + let pushType = null; + let projectId = null; + let senderId = null; + let apiKey = null; + + if (data.fcm) { + pushType = 'fcm'; + projectId = data.fcm.project_id; + } else if (data.gcm) { + pushType = 'gcm'; + senderId = data.gcm.senderId; + apiKey = data.gcm.apiKey; + } + + this.setState({ + isLoading: false, + loadingError: null, + pushType, + projectId, + senderId, + apiKey, + appName: data.appName, + hasPermission, + }); + } catch (err) { + this.setState({ + isLoading: false, + loadingError: getLoadErrorMessage(err, 'Failed to load Android push settings.'), + }); + } + } + + handleFileSelect(file) { + if (!file) { + return; + } + + if (!file.name.endsWith('.json')) { + this.setState({ selectedFile: null, fileError: 'Only .json files are accepted.' }); + if (this.flowSetField) { + this.flowSetField('selectedFileName', ''); + } + return; + } + + const reader = new FileReader(); + reader.onload = (event) => { + if (!this._isMounted) { + return; + } + try { + const json = JSON.parse(event.target.result); + const requiredFields = ['project_id', 'private_key', 'client_email']; + const missing = requiredFields.filter(f => !(f in json)); + if (missing.length > 0) { + this.setState({ + selectedFile: null, + fileError: `JSON is missing required fields: ${missing.join(', ')}`, + }); + if (this.flowSetField) { + this.flowSetField('selectedFileName', ''); + } + return; + } + this.setState({ selectedFile: file, fileError: null }); + if (this.flowSetField) { + this.flowSetField('selectedFileName', file.name); + } + } catch { + this.setState({ selectedFile: null, fileError: 'File is not valid JSON.' }); + if (this.flowSetField) { + this.flowSetField('selectedFileName', ''); + } + } + }; + reader.readAsText(file); + } + + renderCurrentConfig() { + const { pushType, projectId, senderId, apiKey } = this.state; + + if (pushType === 'fcm') { + return ( +
+ } + input={
{projectId}
} + theme={Field.Theme.BLUE} + /> +
+ ); + } + + if (pushType === 'gcm') { + return ( +
+ } + input={
{senderId || 'N/A'}
} + theme={Field.Theme.BLUE} + /> + } + input={
{apiKey || 'N/A'}
} + theme={Field.Theme.BLUE} + /> +
+ ); + } + + return null; + } + + renderForm({ fields, setField }) { + this.flowSetField = setField; + const { hasPermission, pushType, fileError } = this.state; + const noConfig = !pushType; + + return ( +
+
+
+
Android Push Settings
+
+ Manage Push Notification settings for your Android application. + Upload a Firebase Cloud Messaging Service Account JSON to enable push notifications. +
+ + {noConfig && ( + <> + +
+ + )} + + {!noConfig && ( + <> + {this.renderCurrentConfig()} +
+ + )} + +
+
+ + } + input={ +
+ + {this.state.selectedFile && ( +
+ {this.state.selectedFile.name} +
+ )} +
+ } + theme={Field.Theme.BLUE} + /> + {fileError && ( +
+ {fileError} +
+ )} +
+
+
+
+
+ ); + } + + renderContent() { + const toolbar = ; + const { isLoading, loadingError, hasPermission, selectedFile } = this.state; + + let content = null; + + if (loadingError) { + content = ( +
+ { + this.setState({ isLoading: true, loadingError: null }); + this.loadConfig(); + }} + /> +
+ ); + } else if (!isLoading) { + const initialFields = { + selectedFileName: '', + }; + + content = ( +
+ { + if (!hasPermission) { + return true; + } + return !!selectedFile; + }} + validate={() => { + if (!hasPermission) { + return 'use default'; + } + return ''; + }} + defaultFooterMessage={ + You don't have permission to edit push settings. + } + hideButtonsOnDefaultMessage={true} + secondaryButton={() => ( +
+ ); + } + + return ( +
+ +
{content}
+
+ {toolbar} +
+ ); + } +} + +export default PushAndroidSettings; diff --git a/src/dashboard/Push/PushAndroidSettings.scss b/src/dashboard/Push/PushAndroidSettings.scss new file mode 100644 index 000000000..c565b4934 --- /dev/null +++ b/src/dashboard/Push/PushAndroidSettings.scss @@ -0,0 +1,130 @@ +@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; + } +} + +.settingsWrapper { + width: 100%; + padding: 0; + max-width: 870px; + margin: 0 auto; + margin-top: 3rem; +} + +@media only screen and (max-width: 1200px) { + .settingsWrapper { + padding: 0 2rem; + } +} + +.formWrapper { + padding: 0; + width: 100%; +} + +.settingsContainer { + .heading { + @include InterFont; + font-size: 1.125rem; + font-weight: 600; + line-height: 140%; + margin-bottom: 0.5rem; + } + + .subheading { + @include InterFont; + font-size: 0.875rem; + font-weight: 400; + line-height: 140%; + color: $light-grey; + margin-bottom: 1rem; + } + + .fieldHr { + margin: 1.25rem 0; + border: 0; + } +} + +.configValue { + @include InterFont; + font-size: 0.875rem; + font-weight: 400; + line-height: 140%; + padding: 10px 16px; + width: 100%; + text-align: right; + word-break: break-all; + color: $white; +} + +.fileInputWrapper { + display: flex; + flex-direction: column; + align-items: flex-end; + padding: 0 1rem; + width: 100%; + overflow: hidden; + + span { + color: #f2f2f2; + font-size: 1rem; + } + + svg { + fill: #f2f2f2 !important; + } + + > div { + height: auto; + min-height: 0; + text-align: left; + overflow: hidden; + padding: 0; + white-space: normal; + width: 100%; + + > div { + float: none !important; + margin: 0; + height: 24px; + overflow: hidden; + width: 100%; + } + + > span, + > a { + display: none; + } + } +} + +.selectedFileName { + @include InterFont; + max-width: 100%; + font-size: 0.8rem; + color: #f2f2f2; + margin-top: 6px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fileError { + @include InterFont; + font-size: 0.8rem; + color: $error-red; + padding: 6px 16px 10px; +} diff --git a/src/lib/ParseApp.js b/src/lib/ParseApp.js index b307b05e9..64de3407d 100644 --- a/src/lib/ParseApp.js +++ b/src/lib/ParseApp.js @@ -2082,4 +2082,35 @@ export default class ParseApp { throw { error: message }; } } + + async getPushAndroidConfig() { + try { + return ( + await axios.get( + // eslint-disable-next-line no-undef + `${b4aSettings.BACK4APP_API_PATH}/parse-app/${this.slug}/push/android`, + { withCredentials: true } + ) + ).data; + } catch (err) { + throw err.response && err.response.data && err.response.data.error ? err.response.data.error : err; + } + } + + async updatePushAndroidConfig(file) { + try { + const formData = new FormData(); + formData.append('firebaseServiceAccount', file); + return ( + await axios.post( + // eslint-disable-next-line no-undef + `${b4aSettings.BACK4APP_API_PATH}/parse-app/${this.slug}/push/android`, + formData, + { withCredentials: true } + ) + ).data; + } catch (err) { + throw err.response && err.response.data && err.response.data.error ? err.response.data.error : err; + } + } } diff --git a/src/lib/serverInfo.js b/src/lib/serverInfo.js index a27bbf4e3..4958d6295 100644 --- a/src/lib/serverInfo.js +++ b/src/lib/serverInfo.js @@ -28,8 +28,6 @@ export const ALWAYS_ALLOWED_ROUTES = [ 'Social Auth', 'Notification', 'notification', - 'server-url-live-query', - 'Server URL & Live Query', ]; export const canAccess = (serverInfo, route) => { From d3aee0127a354e047c9ceb7cfadfcc8acfdb3f9f Mon Sep 17 00:00:00 2001 From: sadakchap Date: Tue, 14 Apr 2026 15:51:47 +0530 Subject: [PATCH 2/6] preload push andriod --- src/dashboard/Dashboard.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/dashboard/Dashboard.js b/src/dashboard/Dashboard.js index d2eaa2b7a..6029cf16c 100644 --- a/src/dashboard/Dashboard.js +++ b/src/dashboard/Dashboard.js @@ -163,6 +163,7 @@ const preloadMap = { appPlan: () => import('./AppPlan/AppPlan.react'), emailVerification: () => import('./Notification/EmailVerification.react'), emailPasswordReset: () => import('./Notification/EmailPasswordReset.react'), + pushAndroidSettings: () => import('./Push/PushAndroidSettings.react'), }; // Preload all routes with proper error handling and logging From 3dac41612f97af6d4bd82913a94210788632794b Mon Sep 17 00:00:00 2001 From: sadakchap Date: Tue, 14 Apr 2026 16:00:08 +0530 Subject: [PATCH 3/6] fix some styles --- src/dashboard/Push/PushAndroidSettings.react.js | 14 +++++--------- src/dashboard/Push/PushAndroidSettings.scss | 9 +++++++++ 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/dashboard/Push/PushAndroidSettings.react.js b/src/dashboard/Push/PushAndroidSettings.react.js index 94c3b5ca1..4e158613a 100644 --- a/src/dashboard/Push/PushAndroidSettings.react.js +++ b/src/dashboard/Push/PushAndroidSettings.react.js @@ -199,10 +199,9 @@ class PushAndroidSettings extends DashboardView { {noConfig && ( <> - +
+ No push notification configuration found for this application. +

)} @@ -324,12 +323,9 @@ class PushAndroidSettings extends DashboardView { }); }} afterSave={({ resetFields }) => { + resetFields(); this.setState({ selectedFile: null, fileError: null }); - setTimeout(() => { - resetFields(); - this.setState({ isLoading: true }); - this.loadConfig(); - }, 1200); + this.loadConfig(); }} footerContents={() => { if (!selectedFile) { diff --git a/src/dashboard/Push/PushAndroidSettings.scss b/src/dashboard/Push/PushAndroidSettings.scss index c565b4934..3421f0ce8 100644 --- a/src/dashboard/Push/PushAndroidSettings.scss +++ b/src/dashboard/Push/PushAndroidSettings.scss @@ -56,6 +56,15 @@ margin: 1.25rem 0; border: 0; } + + .noConfigMessage { + @include InterFont; + font-size: 0.875rem; + font-weight: 400; + line-height: 140%; + color: $light-grey; + padding: 0.75rem 0; + } } .configValue { From 3f4b93ea9acfb5e6748133cda92b7fd869228e9e Mon Sep 17 00:00:00 2001 From: sadakchap Date: Tue, 14 Apr 2026 16:45:13 +0530 Subject: [PATCH 4/6] revert change --- src/lib/serverInfo.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/lib/serverInfo.js b/src/lib/serverInfo.js index 4958d6295..81cc2f983 100644 --- a/src/lib/serverInfo.js +++ b/src/lib/serverInfo.js @@ -28,6 +28,9 @@ export const ALWAYS_ALLOWED_ROUTES = [ 'Social Auth', 'Notification', 'notification', + 'push', + 'server-url-live-query', + 'Server URL & Live Query', ]; export const canAccess = (serverInfo, route) => { From 1b7e35cf0c7b91c53b99a412e2d60e786dc80ef6 Mon Sep 17 00:00:00 2001 From: sadakchap Date: Tue, 14 Apr 2026 17:49:06 +0530 Subject: [PATCH 5/6] add delete option --- .../Push/PushAndroidSettings.react.js | 86 +++++++++++++++++-- src/dashboard/Push/PushAndroidSettings.scss | 55 ++++++++++++ 2 files changed, 136 insertions(+), 5 deletions(-) diff --git a/src/dashboard/Push/PushAndroidSettings.react.js b/src/dashboard/Push/PushAndroidSettings.react.js index 4e158613a..77634385d 100644 --- a/src/dashboard/Push/PushAndroidSettings.react.js +++ b/src/dashboard/Push/PushAndroidSettings.react.js @@ -9,6 +9,8 @@ import Fieldset from 'components/Fieldset/Fieldset.react'; import Label from 'components/Label/Label.react'; import Button from 'components/Button/Button.react'; import FileInput from 'components/FileInput/FileInput.react'; +import Icon from 'components/Icon/Icon.react'; +import B4aModal from 'components/B4aModal/B4aModal.react'; import EmptyGhostState from 'components/EmptyGhostState/EmptyGhostState.react'; import styles from './PushAndroidSettings.scss'; @@ -43,6 +45,9 @@ class PushAndroidSettings extends DashboardView { hasPermission: true, selectedFile: null, fileError: null, + isDeleting: false, + showDeleteModal: false, + deleteError: null, }; this.flowSetField = null; this.flowViewRef = React.createRef(); @@ -55,6 +60,7 @@ class PushAndroidSettings extends DashboardView { componentWillUnmount() { this._isMounted = false; + clearTimeout(this._deleteErrorTimer); } async loadConfig() { @@ -127,12 +133,12 @@ class PushAndroidSettings extends DashboardView { } return; } - this.setState({ selectedFile: file, fileError: null }); + this.setState({ selectedFile: file, fileError: null, deleteError: null }); if (this.flowSetField) { this.flowSetField('selectedFileName', file.name); } } catch { - this.setState({ selectedFile: null, fileError: 'File is not valid JSON.' }); + this.setState({ selectedFile: null, fileError: 'File is not valid JSON.', deleteError: null }); if (this.flowSetField) { this.flowSetField('selectedFileName', ''); } @@ -141,8 +147,64 @@ class PushAndroidSettings extends DashboardView { reader.readAsText(file); } + async handleDeleteConfig() { + clearTimeout(this._deleteErrorTimer); + this.setState({ isDeleting: true, deleteError: null }); + try { + await this.context.deletePushAndroidConfig(); + this.setState({ isDeleting: false, showDeleteModal: false, isLoading: true }); + this.loadConfig(); + } catch (err) { + const msg = typeof err === 'string' ? err + : (err && err.error) || (err && err.message) || 'Failed to delete push configuration.'; + this.setState({ isDeleting: false, showDeleteModal: false, deleteError: msg }); + this._deleteErrorTimer = setTimeout(() => { + if (this._isMounted) { + this.setState({ deleteError: null }); + } + }, 5000); + } + } + + renderDeleteButton() { + const { hasPermission } = this.state; + if (!hasPermission) { + return null; + } + return ( + + ); + } + + renderDeleteModal() { + if (!this.state.showDeleteModal) { + return null; + } + return ( + this.setState({ showDeleteModal: false })} + onConfirm={this.handleDeleteConfig.bind(this)} + progress={this.state.isDeleting} + disabled={this.state.isDeleting} + /> + ); + } + renderCurrentConfig() { - const { pushType, projectId, senderId, apiKey } = this.state; + const { pushType, projectId, senderId, apiKey, deleteError } = this.state; if (pushType === 'fcm') { return ( @@ -152,9 +214,17 @@ class PushAndroidSettings extends DashboardView { > } - input={
{projectId}
} + input={ +
+
{projectId}
+ {this.renderDeleteButton()} +
+ } theme={Field.Theme.BLUE} /> + {deleteError && ( +
{deleteError}
+ )} ); } @@ -167,7 +237,12 @@ class PushAndroidSettings extends DashboardView { > } - input={
{senderId || 'N/A'}
} + input={ +
+
{senderId || 'N/A'}
+ {this.renderDeleteButton()} +
+ } theme={Field.Theme.BLUE} /> {content} {toolbar} + {this.renderDeleteModal()} ); } diff --git a/src/dashboard/Push/PushAndroidSettings.scss b/src/dashboard/Push/PushAndroidSettings.scss index 3421f0ce8..1189892c8 100644 --- a/src/dashboard/Push/PushAndroidSettings.scss +++ b/src/dashboard/Push/PushAndroidSettings.scss @@ -67,6 +67,12 @@ } } +.configValueRow { + display: flex; + align-items: center; + width: 100%; +} + .configValue { @include InterFont; font-size: 0.875rem; @@ -79,6 +85,30 @@ color: $white; } +.deleteButton { + background: none; + border: none; + cursor: pointer; + padding: 6px; + margin-right: 8px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + opacity: 0.8; + transition: opacity 0.15s ease; + flex-shrink: 0; + + &:hover { + opacity: 1; + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } +} + .fileInputWrapper { display: flex; flex-direction: column; @@ -137,3 +167,28 @@ color: $error-red; padding: 6px 16px 10px; } + +@keyframes slideDown { + from { + max-height: 0; + padding-top: 0; + padding-bottom: 0; + opacity: 0; + } + to { + max-height: 100px; + padding-top: 6px; + padding-bottom: 10px; + opacity: 1; + } +} + +.deleteError { + background: $error-red; + font-size: 0.8rem; + text-align: center; + color: $white; + padding: 6px 16px 10px; + overflow: hidden; + animation: slideDown 0.3s ease-out forwards; +} From 39bf4951a4f3fec387063b1a36ea8de70ea31e9c Mon Sep 17 00:00:00 2001 From: sadakchap Date: Tue, 14 Apr 2026 18:11:19 +0530 Subject: [PATCH 6/6] add api call --- src/lib/ParseApp.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/lib/ParseApp.js b/src/lib/ParseApp.js index 64de3407d..6ff34cfa8 100644 --- a/src/lib/ParseApp.js +++ b/src/lib/ParseApp.js @@ -2113,4 +2113,18 @@ export default class ParseApp { throw err.response && err.response.data && err.response.data.error ? err.response.data.error : err; } } + + async deletePushAndroidConfig() { + try { + return ( + await axios.delete( + // eslint-disable-next-line no-undef + `${b4aSettings.BACK4APP_API_PATH}/parse-app/${this.slug}/push/android`, + { withCredentials: true } + ) + ).data; + } catch (err) { + throw err.response && err.response.data && err.response.data.error ? err.response.data.error : err; + } + } }