diff --git a/src/dashboard/Dashboard.js b/src/dashboard/Dashboard.js index 54a3d1f19..6029cf16c 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() { @@ -162,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 @@ -504,6 +506,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..77634385d --- /dev/null +++ b/src/dashboard/Push/PushAndroidSettings.react.js @@ -0,0 +1,433 @@ +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 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'; + +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, + isDeleting: false, + showDeleteModal: false, + deleteError: null, + }; + this.flowSetField = null; + this.flowViewRef = React.createRef(); + } + + componentDidMount() { + this._isMounted = true; + this.loadConfig(); + } + + componentWillUnmount() { + this._isMounted = false; + clearTimeout(this._deleteErrorTimer); + } + + 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, deleteError: null }); + if (this.flowSetField) { + this.flowSetField('selectedFileName', file.name); + } + } catch { + this.setState({ selectedFile: null, fileError: 'File is not valid JSON.', deleteError: null }); + if (this.flowSetField) { + this.flowSetField('selectedFileName', ''); + } + } + }; + 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 ( + this.setState({ showDeleteModal: true, deleteError: null })} + aria-label="Delete Android push configuration" + title="Delete configuration" + > + + + ); + } + + 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, deleteError } = this.state; + + if (pushType === 'fcm') { + return ( + + } + input={ + + {projectId} + {this.renderDeleteButton()} + + } + theme={Field.Theme.BLUE} + /> + {deleteError && ( + {deleteError} + )} + + ); + } + + if (pushType === 'gcm') { + return ( + + } + input={ + + {senderId || 'N/A'} + {this.renderDeleteButton()} + + } + 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 && ( + <> + + No push notification configuration found for this application. + + + > + )} + + {!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={() => ( + { + this.setState({ selectedFile: null, fileError: null }); + if (this.flowViewRef.current) { + this.flowViewRef.current.resetFields(); + } + }} + color="white" + dark={true} + value="Cancel" + /> + )} + onSubmit={() => { + return this.context.updatePushAndroidConfig(selectedFile).catch(err => { + const msg = typeof err === 'string' ? err + : (err && err.error) || (err && err.message) || 'Failed to save push settings.'; + return Promise.reject({ error: msg }); + }); + }} + afterSave={({ resetFields }) => { + resetFields(); + this.setState({ selectedFile: null, fileError: null }); + this.loadConfig(); + }} + footerContents={() => { + if (!selectedFile) { + return null; + } + return ( + + Upload {selectedFile.name} as the new Firebase Service Account. + + ); + }} + renderForm={this.renderForm.bind(this)} + /> + + ); + } + + return ( + + + {content} + + {toolbar} + {this.renderDeleteModal()} + + ); + } +} + +export default PushAndroidSettings; diff --git a/src/dashboard/Push/PushAndroidSettings.scss b/src/dashboard/Push/PushAndroidSettings.scss new file mode 100644 index 000000000..1189892c8 --- /dev/null +++ b/src/dashboard/Push/PushAndroidSettings.scss @@ -0,0 +1,194 @@ +@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; + } + + .noConfigMessage { + @include InterFont; + font-size: 0.875rem; + font-weight: 400; + line-height: 140%; + color: $light-grey; + padding: 0.75rem 0; + } +} + +.configValueRow { + display: flex; + align-items: center; + width: 100%; +} + +.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; +} + +.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; + 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; +} + +@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; +} diff --git a/src/lib/ParseApp.js b/src/lib/ParseApp.js index b307b05e9..6ff34cfa8 100644 --- a/src/lib/ParseApp.js +++ b/src/lib/ParseApp.js @@ -2082,4 +2082,49 @@ 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; + } + } + + 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; + } + } } diff --git a/src/lib/serverInfo.js b/src/lib/serverInfo.js index a27bbf4e3..81cc2f983 100644 --- a/src/lib/serverInfo.js +++ b/src/lib/serverInfo.js @@ -28,6 +28,7 @@ export const ALWAYS_ALLOWED_ROUTES = [ 'Social Auth', 'Notification', 'notification', + 'push', 'server-url-live-query', 'Server URL & Live Query', ];