diff --git a/src/components/B4aModal/B4aModal.react.js b/src/components/B4aModal/B4aModal.react.js index f423b6000c..6a6e116908 100644 --- a/src/components/B4aModal/B4aModal.react.js +++ b/src/components/B4aModal/B4aModal.react.js @@ -92,7 +92,14 @@ const B4aModal = ({ return (
- {showCancel && } + {showCancel && ( + + + + )}
{iconNode ? (
diff --git a/src/components/Sidebar/B4aSidebar.react.js b/src/components/Sidebar/B4aSidebar.react.js index 4731c34661..d89a872049 100644 --- a/src/components/Sidebar/B4aSidebar.react.js +++ b/src/components/Sidebar/B4aSidebar.react.js @@ -123,18 +123,31 @@ const B4aSidebar = ({ if (groupChildren) { const isGroupActive = subsection === name || groupChildren.some(c => c.name === subsection); const groupLink = link.startsWith('/') ? prefix + link : link; + const groupRoute = link.replace(/^\//, ''); + const groupDisabled = !canAccess(currentApp.serverInfo, groupRoute); return (
- - {name} - + {groupDisabled ? ( + + {name} + + ) : ( + + {name} + + )}
{groupChildren.map(child => { const childActive = subsection === child.name; const childLink = child.link.startsWith('/') ? prefix + child.link : child.link; + const childRoute = child.link.replace(/^\//, ''); + const childDisabled = !canAccess(currentApp.serverInfo, childRoute); return ( {childActive ? children : null} diff --git a/src/components/Sidebar/SidebarSubItem.react.js b/src/components/Sidebar/SidebarSubItem.react.js index 8757eb7ea6..18d5dedd19 100644 --- a/src/components/Sidebar/SidebarSubItem.react.js +++ b/src/components/Sidebar/SidebarSubItem.react.js @@ -16,7 +16,18 @@ const sendEvent = () => { back4AppNavigation.atApiReferenceIntroEvent(); }; -let SidebarSubItem = ({ active, name, action, link, children, badge }) => { +let SidebarSubItem = ({ active, name, action, link, children, badge, disabled }) => { + if (disabled) { + return ( +
+ + {name} + {badge ? : null} + +
+ ); + } + if (active) { return (
diff --git a/src/dashboard/AppData.react.js b/src/dashboard/AppData.react.js index 5f74daa981..305bf82e74 100644 --- a/src/dashboard/AppData.react.js +++ b/src/dashboard/AppData.react.js @@ -31,8 +31,10 @@ function AppData() { } current.setParseKeys(); - const curPathName = window.location.pathname.split('/')[3]; - if (current.serverInfo.error && !canAccess(current.serverInfo, curPathName)) { + const pathSegments = window.location.pathname.split('/'); + const curPathName = pathSegments[3]; + const fullSubRoute = pathSegments.slice(3, 5).join('/'); + if (current.serverInfo.error && !canAccess(current.serverInfo, curPathName) && !canAccess(current.serverInfo, fullSubRoute)) { navigate(`/apps/${current.slug}/overview`, { replace: true }); return
; // return ( diff --git a/src/dashboard/Dashboard.js b/src/dashboard/Dashboard.js index 6029cf16ce..1afc6c031b 100644 --- a/src/dashboard/Dashboard.js +++ b/src/dashboard/Dashboard.js @@ -83,6 +83,7 @@ const LazyDatabaseProfile = lazy(() => import('./DatabaseProfiler/DatabaseProfil const LazyEmailVerification = lazy(() => import('./Notification/EmailVerification.react')); const LazyEmailPasswordReset = lazy(() => import('./Notification/EmailPasswordReset.react')); const LazyPushAndroidSettings = lazy(() => import('./Push/PushAndroidSettings.react')); +const LazyPushiOSSettings = lazy(() => import('./Push/PushiOSSettings.react')); async function fetchHubUser() { @@ -164,13 +165,14 @@ const preloadMap = { emailVerification: () => import('./Notification/EmailVerification.react'), emailPasswordReset: () => import('./Notification/EmailPasswordReset.react'), pushAndroidSettings: () => import('./Push/PushAndroidSettings.react'), + pushiOSSettings: () => import('./Push/PushiOSSettings.react'), }; // Preload all routes with proper error handling and logging const preloadRoute = async (routeName, preloadFn) => { try { await preloadFn(); - console.log(`Successfully preloaded route: ${routeName}`); + // console.log(`Successfully preloaded route: ${routeName}`); } catch (err) { console.error(`Error preloading route ${routeName}:`, err); } @@ -178,7 +180,7 @@ const preloadRoute = async (routeName, preloadFn) => { // Preload all routes in parallel const preloadAllRoutes = () => { - console.log('Preloading routes...'); + // console.log('Preloading routes...'); return Promise.all( Object.entries(preloadMap).map(([routeName, preloadFn]) => preloadRoute(routeName, preloadFn) @@ -217,7 +219,7 @@ class Dashboard extends React.Component { componentDidMount() { // Start preloading routes immediately but don't block on it preloadAllRoutes().finally(() => { - console.log('Route preloading complete'); + // console.log('Route preloading complete'); }); get('/parse-dashboard-config.json').then(({ apps, newFeaturesInLatestVersion = [], user }) => { @@ -507,6 +509,7 @@ class Dashboard extends React.Component { } /> } /> } /> + } /> } /> } /> diff --git a/src/dashboard/DashboardView.react.js b/src/dashboard/DashboardView.react.js index f5b990a081..76b2c3a5b4 100644 --- a/src/dashboard/DashboardView.react.js +++ b/src/dashboard/DashboardView.react.js @@ -83,7 +83,11 @@ export default class DashboardView extends React.Component { } render() { - const isLocked = !canAccess(this.context.serverInfo, window.location.pathname.split('/')[3]); + const pathSegments = window.location.pathname.split('/'); + const topRoute = pathSegments[3]; + const fullSubRoute = pathSegments.slice(3, 5).join('/'); + const isLocked = !canAccess(this.context.serverInfo, topRoute) + && !canAccess(this.context.serverInfo, fullSubRoute); if (isLocked) { return (
@@ -349,19 +353,19 @@ export default class DashboardView extends React.Component { ], }, { - name: 'Pushes', + name: 'Push', link: '/push/new', children: [ { name: 'Send New Push', link: '/push/new' }, - { name: 'Past Pushes', link: '/push/activity' }, + { name: 'History', link: '/push/activity' }, { name: 'Audiences', link: '/push/audiences' }, - { name: 'Android', link: '/push/android-settings' }, + { name: 'Setup', link: '/push/android-settings' }, ], }, ]; appSidebarSections.push({ - name: 'Notification', + name: 'Notifications', icon: 'b4a-push-notification-icon', link: '/notification', subsections: notificationSubSections, diff --git a/src/dashboard/Data/AppOverview/AppLoadingText.react.js b/src/dashboard/Data/AppOverview/AppLoadingText.react.js index b8d92b4a7c..d93367d1c8 100644 --- a/src/dashboard/Data/AppOverview/AppLoadingText.react.js +++ b/src/dashboard/Data/AppOverview/AppLoadingText.react.js @@ -32,7 +32,7 @@ const AppLoadingText = ({ appName, appId, pollSchemas }) => { document.documentElement.style.setProperty('--text-interval', `${TEXT_INTERVAL}ms`); document.documentElement.style.setProperty('--fill-duration', `${TEXT_INTERVAL / 2}ms`); return () => { - console.log('deleting cookie'); + // console.log('deleting cookie'); try { document.cookie = `newApp-${appId}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; domain=back4app.com`; } catch (error) { diff --git a/src/dashboard/Notification/EmailPasswordReset.react.js b/src/dashboard/Notification/EmailPasswordReset.react.js index ad5c39827d..6e7b956a06 100644 --- a/src/dashboard/Notification/EmailPasswordReset.react.js +++ b/src/dashboard/Notification/EmailPasswordReset.react.js @@ -91,7 +91,7 @@ const renderChangedValuesFooter = (changes, fieldOptions) => { class EmailPasswordReset extends DashboardView { constructor() { super(); - this.section = 'Notification'; + this.section = 'Notifications'; this.subsection = 'Password Reset'; this.state = { isLoading: true, @@ -345,7 +345,7 @@ class EmailPasswordReset extends DashboardView { renderContent() { const toolbar = ( - + ); const { isLoading, initialFields, errorMessage, hasPermission, isUserVerified } = this.state; diff --git a/src/dashboard/Notification/EmailVerification.react.js b/src/dashboard/Notification/EmailVerification.react.js index 662f040b7c..c3a06cc6c3 100644 --- a/src/dashboard/Notification/EmailVerification.react.js +++ b/src/dashboard/Notification/EmailVerification.react.js @@ -17,6 +17,7 @@ import B4aModal from 'components/B4aModal/B4aModal.react'; import Button from 'components/Button/Button.react'; import { Link } from 'react-router-dom'; import validateEmailFormat from 'lib/validateEmailFormat'; +import { amplitudeLogEvent } from 'lib/amplitudeEvents'; const DEFAULT_VERIFICATION_BODY = 'Hi,\n\n' + @@ -99,7 +100,7 @@ const renderChangedValuesFooter = (changes, fieldOptions) => { class EmailVerification extends DashboardView { constructor() { super(); - this.section = 'Notification'; + this.section = 'Notifications'; this.subsection = 'Verification'; this.state = { isLoading: true, @@ -483,7 +484,7 @@ class EmailVerification extends DashboardView { renderContent() { const toolbar = ( - + ); const { isLoading, initialFields, errorMessage, hasPermission, isUserVerified, canChangeEmailTemplate } = this.state; @@ -553,6 +554,7 @@ class EmailVerification extends DashboardView { return this.context.updateEmailSettings(emailSettings, preventLoginWithUnverifiedEmail); }} afterSave={({ fields, resetFields }) => { + amplitudeLogEvent('Verification email configured'); this.setState({ initialFields: { ...fields }, isDirty: false, diff --git a/src/dashboard/Push/P12CertificateModal.react.js b/src/dashboard/Push/P12CertificateModal.react.js new file mode 100644 index 0000000000..c5071c1e4a --- /dev/null +++ b/src/dashboard/Push/P12CertificateModal.react.js @@ -0,0 +1,137 @@ +import React, { useState, useCallback } from 'react'; +import B4aModal from 'components/B4aModal/B4aModal.react'; +import Field from 'components/Field/Field.react'; +import Label from 'components/Label/Label.react'; +import FileInput from 'components/FileInput/FileInput.react'; +import Dropdown from 'components/Dropdown/Dropdown.react'; +import Option from 'components/Dropdown/Option.react'; +import RadioButton from 'components/RadioButton/RadioButton.react'; +import FormNote from 'components/FormNote/FormNote.react'; +import styles from './PushiOSSettings.scss'; + +function validate(fields) { + const errors = {}; + + if (!fields.file) { + errors.file = 'APNs certificate file is required.'; + } else if (!fields.file.name.endsWith('.p12')) { + errors.file = 'File must have a .p12 extension.'; + } + + if (!fields.deviceType) { + errors.deviceType = 'Device type is required.'; + } + + return errors; +} + +const P12CertificateModal = ({ deviceTypes, onSave, onClose }) => { + const [file, setFile] = useState(null); + const [deviceType, setDeviceType] = useState('ios'); + const [production, setProduction] = useState(true); + const [errors, setErrors] = useState({}); + const [saving, setSaving] = useState(false); + const [serverError, setServerError] = useState(null); + + const handleSubmit = useCallback(async () => { + const fields = { file, deviceType }; + const validationErrors = validate(fields); + setErrors(validationErrors); + + if (Object.keys(validationErrors).length > 0) { + return; + } + + setSaving(true); + setServerError(null); + try { + await onSave(file, deviceType, production); + setSaving(false); + onClose(); + } catch (err) { + const msg = typeof err === 'string' ? err + : (err && err.error) || (err && err.message) || 'Failed to save certificate.'; + setServerError(msg); + setSaving(false); + } + }, [file, deviceType, production, onSave]); + + const firstError = Object.values(errors).find(Boolean); + + return ( + + } + input={ +
+ { setFile(f); setErrors(prev => ({ ...prev, file: undefined })); setServerError(null); }} + accept=".p12,application/x-pkcs12" + value={file ? { name: file.name } : undefined} + /> +
+ } + /> + } + input={ + { setDeviceType(value); setErrors(prev => ({ ...prev, deviceType: undefined })); }} + placeHolder="Select device type" + dark={false} + fixed={true} + > + {deviceTypes.map(type => ( + + ))} + + } + /> + } + input={ +
+ + +
+ } + /> + + {firstError} + + + {serverError} + +
+ ); +}; + +export default P12CertificateModal; diff --git a/src/dashboard/Push/P8AuthKeyModal.react.js b/src/dashboard/Push/P8AuthKeyModal.react.js new file mode 100644 index 0000000000..b537b32e5f --- /dev/null +++ b/src/dashboard/Push/P8AuthKeyModal.react.js @@ -0,0 +1,225 @@ +import React, { useState, useCallback } from 'react'; +import B4aModal from 'components/B4aModal/B4aModal.react'; +import Field from 'components/Field/Field.react'; +import Label from 'components/Label/Label.react'; +import TextInput from 'components/TextInput/TextInput.react'; +import FileInput from 'components/FileInput/FileInput.react'; +import Dropdown from 'components/Dropdown/Dropdown.react'; +import Option from 'components/Dropdown/Option.react'; +import RadioButton from 'components/RadioButton/RadioButton.react'; +import FormNote from 'components/FormNote/FormNote.react'; +import styles from './PushiOSSettings.scss'; + +function validate(fields) { + const errors = {}; + + if (!fields.file) { + errors.file = 'APNs auth key file is required.'; + } else if (!fields.file.name.endsWith('.p8')) { + errors.file = 'File must have a .p8 extension.'; + } + + if (!fields.keyId || !fields.keyId.trim()) { + errors.keyId = 'Key ID is required.'; + } else if (fields.keyId.trim().length !== 10) { + errors.keyId = 'Key ID must be exactly 10 characters.'; + } + + if (!fields.teamId || !fields.teamId.trim()) { + errors.teamId = 'Team ID is required.'; + } else if (fields.teamId.trim().length !== 10) { + errors.teamId = 'Team ID must be exactly 10 characters.'; + } + + if (!fields.bundleId || !fields.bundleId.trim()) { + errors.bundleId = 'Bundle ID is required.'; + } + + if (!fields.deviceType) { + errors.deviceType = 'Device type is required.'; + } + + return errors; +} + +const P8AuthKeyModal = ({ deviceTypes, hasP12Certificates, onSave, onClose }) => { + const [file, setFile] = useState(null); + const [keyId, setKeyId] = useState(''); + const [teamId, setTeamId] = useState(''); + const [bundleId, setBundleId] = useState(''); + const [deviceType, setDeviceType] = useState('ios'); + const [production, setProduction] = useState(true); + const [errors, setErrors] = useState({}); + const [saving, setSaving] = useState(false); + const [serverError, setServerError] = useState(null); + + const handleSubmit = useCallback(async () => { + const fields = { file, keyId, teamId, bundleId, deviceType }; + const validationErrors = validate(fields); + setErrors(validationErrors); + + if (Object.keys(validationErrors).length > 0) { + return; + } + + if (hasP12Certificates) { + const confirmed = window.confirm( + 'Are you sure? By saving your authentication keys, your p12 certificates will be removed.' + ); + if (!confirmed) { + return; + } + } + + setSaving(true); + setServerError(null); + try { + await onSave(file, keyId.trim(), teamId.trim(), bundleId.trim(), deviceType, production); + setSaving(false); + onClose(); + } catch (err) { + const msg = typeof err === 'string' ? err + : (err && err.error) || (err && err.message) || 'Failed to save authentication key.'; + setServerError(msg); + setSaving(false); + } + }, [file, keyId, teamId, bundleId, deviceType, production, hasP12Certificates, onSave]); + + const firstError = Object.values(errors).find(Boolean); + const isFormValid = file && file.name.endsWith('.p8') + && keyId.trim().length === 10 + && teamId.trim().length === 10 + && bundleId.trim().length > 0 + && !!deviceType; + + return ( + + } + input={ +
+ { setFile(f); setErrors(prev => ({ ...prev, file: undefined })); setServerError(null); }} + accept=".p8" + value={file ? { name: file.name } : undefined} + /> +
+ } + /> + } + input={ + { + if (value.length <= 10) { + setKeyId(value); + if (value.trim().length === 10 || value.trim().length === 0) { + setErrors(prev => ({ ...prev, keyId: undefined })); + } else { + setErrors(prev => ({ ...prev, keyId: 'Key ID must be exactly 10 characters.' })); + } + } + }} + /> + } + /> + } + input={ + { + if (value.length <= 10) { + setTeamId(value); + if (value.trim().length === 10 || value.trim().length === 0) { + setErrors(prev => ({ ...prev, teamId: undefined })); + } else { + setErrors(prev => ({ ...prev, teamId: 'Team ID must be exactly 10 characters.' })); + } + } + }} + /> + } + /> + } + input={ + { setBundleId(value); setErrors(prev => ({ ...prev, bundleId: undefined })); }} + /> + } + /> + } + input={ + { setDeviceType(value); setErrors(prev => ({ ...prev, deviceType: undefined })); }} + placeHolder="Select device type" + dark={false} + fixed={true} + > + {deviceTypes.map(type => ( + + ))} + + } + /> + } + input={ +
+ + +
+ } + /> + + {firstError} + + + {serverError} + +
+ ); +}; + +export default P8AuthKeyModal; diff --git a/src/dashboard/Push/PushAndroidSettings.react.js b/src/dashboard/Push/PushAndroidSettings.react.js index 77634385d6..7ae8f66a9b 100644 --- a/src/dashboard/Push/PushAndroidSettings.react.js +++ b/src/dashboard/Push/PushAndroidSettings.react.js @@ -2,6 +2,7 @@ import React from 'react'; import { withRouter } from 'lib/withRouter'; import Toolbar from 'components/Toolbar/Toolbar.react'; import DashboardView from 'dashboard/DashboardView.react'; +import CategoryList from 'components/CategoryList/CategoryList.react'; import B4aLoaderContainer from 'components/B4aLoaderContainer/B4aLoaderContainer.react'; import FlowView from 'components/FlowView/FlowView.react'; import Field from 'components/Field/Field.react'; @@ -12,6 +13,7 @@ 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 { amplitudeLogEvent } from 'lib/amplitudeEvents'; import styles from './PushAndroidSettings.scss'; const getLoadErrorMessage = (err, fallbackMessage) => { @@ -32,8 +34,8 @@ const getLoadErrorMessage = (err, fallbackMessage) => { class PushAndroidSettings extends DashboardView { constructor() { super(); - this.section = 'Notification'; - this.subsection = 'Android'; + this.section = 'Notifications'; + this.subsection = 'Setup'; this.state = { isLoading: true, loadingError: null, @@ -329,8 +331,23 @@ class PushAndroidSettings extends DashboardView { ); } + renderSidebar() { + const { pathname } = this.props.location; + const current = pathname.substr(pathname.lastIndexOf('/') + 1, pathname.length - 1); + return ( + + ); + } + renderContent() { - const toolbar = ; + const toolbar = ; const { isLoading, loadingError, hasPermission, selectedFile } = this.state; let content = null; @@ -398,6 +415,7 @@ class PushAndroidSettings extends DashboardView { }); }} afterSave={({ resetFields }) => { + amplitudeLogEvent('Configured Android Push'); resetFields(); this.setState({ selectedFile: null, fileError: null }); this.loadConfig(); diff --git a/src/dashboard/Push/PushAudiencesIndex.react.js b/src/dashboard/Push/PushAudiencesIndex.react.js index e0969e1533..ae822decb5 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 = 'Notification'; + this.section = 'Notifications'; this.subsection = 'Audiences'; this.state = { availableDevices: [], diff --git a/src/dashboard/Push/PushDetails.react.js b/src/dashboard/Push/PushDetails.react.js index 51fad95f4d..5247ac21e5 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 = 'Notification'; + this.section = 'Notifications'; this.subsection = ''; this.state = { pushDetails: {}, diff --git a/src/dashboard/Push/PushIndex.react.js b/src/dashboard/Push/PushIndex.react.js index 9e6d5d2319..2f587c22c7 100644 --- a/src/dashboard/Push/PushIndex.react.js +++ b/src/dashboard/Push/PushIndex.react.js @@ -228,8 +228,8 @@ const getPushTime = (pushTime, updatedAt) => { class PushIndex extends DashboardView { constructor() { super(); - this.section = 'Notification'; - this.subsection = 'Past Pushes'; + this.section = 'Notifications'; + this.subsection = 'History'; this.action = new SidebarAction('Send a push', this.navigateToNew.bind(this)); this.state = { pushes: [], diff --git a/src/dashboard/Push/PushNew.react.js b/src/dashboard/Push/PushNew.react.js index c755719178..e89006727e 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 = 'Notification'; + this.section = 'Notifications'; this.subsection = 'Send New Push'; this.state = { pushAudiencesFetched: false, diff --git a/src/dashboard/Push/PushiOSSettings.react.js b/src/dashboard/Push/PushiOSSettings.react.js new file mode 100644 index 0000000000..c069b5b944 --- /dev/null +++ b/src/dashboard/Push/PushiOSSettings.react.js @@ -0,0 +1,512 @@ +import React from 'react'; +import { withRouter } from 'lib/withRouter'; +import Toolbar from 'components/Toolbar/Toolbar.react'; +import DashboardView from 'dashboard/DashboardView.react'; +import CategoryList from 'components/CategoryList/CategoryList.react'; +import B4aLoaderContainer from 'components/B4aLoaderContainer/B4aLoaderContainer.react'; +import B4aModal from 'components/B4aModal/B4aModal.react'; +import Button from 'components/Button/Button.react'; +import Icon from 'components/Icon/Icon.react'; +import EmptyGhostState from 'components/EmptyGhostState/EmptyGhostState.react'; +import P8AuthKeyModal from './P8AuthKeyModal.react'; +import P12CertificateModal from './P12CertificateModal.react'; +import B4aTooltip from 'components/Tooltip/B4aTooltip.react'; +import { amplitudeLogEvent } from 'lib/amplitudeEvents'; +import styles from './PushiOSSettings.scss'; + +const getErrorMessage = (err, fallback) => { + 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 fallback; +}; + +const formatDate = (isoString) => { + if (!isoString) { + return 'N/A'; + } + const d = new Date(isoString); + if (isNaN(d.getTime())) { + return 'N/A'; + } + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const month = months[d.getMonth()]; + const day = String(d.getDate()).padStart(2, '0'); + const year = d.getFullYear(); + return `${month}/${day}/${year}`; +}; + +@withRouter +class PushiOSSettings extends DashboardView { + constructor() { + super(); + this.section = 'Notifications'; + this.subsection = 'Setup'; + this.state = { + isLoading: true, + loadingError: null, + p8Certificates: [], + p12Certificates: [], + deviceTypes: [], + disableP12: false, + incompatiblePS: false, + hasPermission: true, + showP8Modal: false, + showP12Modal: false, + showDeleteModal: false, + deleteTarget: null, + isDeleting: false, + deleteError: null, + showP8Tooltip: false, + showP12Tooltip: false, + }; + } + + componentDidMount() { + this._isMounted = true; + this.loadConfig(); + } + + componentWillUnmount() { + this._isMounted = false; + clearTimeout(this._deleteErrorTimer); + } + + async loadConfig() { + try { + const data = await this.context.getPushIOSConfig(); + if (!this._isMounted) { + return; + } + const hasPermission = !data.featuresPermission + || data.featuresPermission.pushIOSSettings === 'Write'; + + const p12Certificates = []; + if (data.cert && typeof data.cert === 'object') { + for (const [deviceType, certs] of Object.entries(data.cert)) { + if (Array.isArray(certs)) { + certs.forEach(c => p12Certificates.push({ ...c, deviceType })); + } + } + } + + const p8Certificates = []; + if (data.authKey && typeof data.authKey === 'object') { + for (const [deviceType, certs] of Object.entries(data.authKey)) { + if (Array.isArray(certs)) { + certs.forEach(c => p8Certificates.push({ + _id: c._id, + topic: c.topic, + keyId: c.token?.keyId, + teamId: c.token?.teamId, + production: c.production, + deviceType, + })); + } + } + } + + this.setState({ + isLoading: false, + loadingError: null, + p8Certificates, + p12Certificates, + deviceTypes: [...new Set([...(data.deviceTypes || []), 'tvos'])], + disableP12: !!data.disableP12, + incompatiblePS: !!data.isCompatiblePS, + hasPermission, + }); + } catch (err) { + if (this._isMounted) { + this.setState({ + isLoading: false, + loadingError: getErrorMessage(err, 'Failed to load iOS push settings.'), + }); + } + } + } + + handleSaveP8 = async (file, keyId, teamId, bundleId, deviceType, production) => { + await this.context.uploadP8AuthKey(file, keyId, teamId, bundleId, deviceType, production); + amplitudeLogEvent('Configured iOS Push'); + this.setState({ isLoading: true }); + this.loadConfig(); + }; + + handleSaveP12 = async (file, deviceType, production) => { + await this.context.uploadP12Certificate(file, deviceType, production); + amplitudeLogEvent('Configured iOS Push'); + this.setState({ isLoading: true }); + this.loadConfig(); + }; + + handleCloseP8Modal = () => { + this.setState({ showP8Modal: false }); + }; + + handleCloseP12Modal = () => { + this.setState({ showP12Modal: false }); + }; + + openDeleteModal(certId, deviceType, certType) { + this.setState({ + showDeleteModal: true, + deleteTarget: { id: certId, deviceType, certType }, + deleteError: null, + }); + } + + async handleDeleteConfirm() { + const { deleteTarget } = this.state; + if (!deleteTarget) { + return; + } + + clearTimeout(this._deleteErrorTimer); + this.setState({ isDeleting: true, deleteError: null }); + + try { + if (deleteTarget.certType === 'p8') { + await this.context.deleteP8AuthKey(deleteTarget.id, deleteTarget.deviceType); + } else { + await this.context.deleteP12Certificate(deleteTarget.id, deleteTarget.deviceType); + } + this.setState({ + isDeleting: false, + showDeleteModal: false, + deleteTarget: null, + isLoading: true, + }); + this.loadConfig(); + } catch (err) { + const msg = getErrorMessage(err, 'Failed to delete certificate.'); + this.setState({ isDeleting: false, showDeleteModal: false, deleteError: msg }); + this._deleteErrorTimer = setTimeout(() => { + if (this._isMounted) { + this.setState({ deleteError: null }); + } + }, 5000); + } + } + + renderP8Table() { + const { p8Certificates, hasPermission } = this.state; + + if (p8Certificates.length === 0) { + return
No authentication keys configured.
; + } + + return ( + + + + + + + + + + + + + {p8Certificates.map(cert => ( + + + + + + + + + ))} + +
TopicKey IDTeam IDProductionDevice Type
{cert.topic}{cert.keyId}{cert.teamId}{cert.production ? 'true' : 'false'}{cert.deviceType} + +
+ ); + } + + renderP12Table() { + const { p12Certificates, hasPermission } = this.state; + + if (p12Certificates.length === 0) { + return
No certificates configured.
; + } + + return ( + + + + + + + + + + + + {p12Certificates.map(cert => ( + + + + + + + + ))} + +
Bundle IDProductionExpirationDevice Type
{cert.bundleId}{cert.production ? 'Production' : 'Development'}{formatDate(cert.expiresAt)}{cert.deviceType} + +
+ ); + } + + renderDeleteModal() { + if (!this.state.showDeleteModal) { + return null; + } + const label = this.state.deleteTarget?.certType === 'p8' + ? 'authentication key' : 'certificate'; + return ( + this.setState({ showDeleteModal: false, deleteTarget: null })} + onConfirm={this.handleDeleteConfirm.bind(this)} + progress={this.state.isDeleting} + disabled={this.state.isDeleting} + canCancel={!this.state.isDeleting} + /> + ); + } + + renderSidebar() { + const { pathname } = this.props.location; + const current = pathname.substr(pathname.lastIndexOf('/') + 1, pathname.length - 1); + return ( + + ); + } + + renderContent() { + const toolbar = ( + + { + this.setState({ isLoading: true, loadingError: null }); + this.loadConfig(); + }} + title="Refresh" + > + + + + ); + const { + isLoading, + loadingError, + hasPermission, + incompatiblePS, + disableP12, + deviceTypes, + deleteError, + } = this.state; + + let content = null; + + if (loadingError) { + content = ( +
+ { + this.setState({ isLoading: true, loadingError: null }); + this.loadConfig(); + }} + /> +
+ ); + } else if (!isLoading) { + content = ( +
+
+
+
Apple Push Settings
+
+ Manage Apple Push Notification settings. +
+ +
+ We recommend using APNs Authentication Keys (.p8) as they are the most current and reliable method for sending push notifications. +
+ +
+
+
+

APNs Authentication Key

+ {incompatiblePS ? ( + + this.setState({ showP8Tooltip: true })} + onMouseLeave={() => this.setState({ showP8Tooltip: false })} + > +
+ {this.renderP8Table()} +
+ +
+ +
+
+

APNs Certificates

+ {disableP12 ? ( + + this.setState({ showP12Tooltip: true })} + onMouseLeave={() => this.setState({ showP12Tooltip: false })} + > +
+ {this.renderP12Table()} +
+
+ + {deleteError && ( +
{deleteError}
+ )} +
+
+
+ ); + } + + return ( +
+ +
{content}
+
+ {toolbar} + {this.renderDeleteModal()} + {this.state.showP8Modal && ( + 0} + onSave={this.handleSaveP8} + onClose={this.handleCloseP8Modal} + /> + )} + {this.state.showP12Modal && ( + + )} +
+ ); + } +} + +export default PushiOSSettings; diff --git a/src/dashboard/Push/PushiOSSettings.scss b/src/dashboard/Push/PushiOSSettings.scss new file mode 100644 index 0000000000..bbcd7a3e95 --- /dev/null +++ b/src/dashboard/Push/PushiOSSettings.scss @@ -0,0 +1,260 @@ +@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; + } +} + +.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: 0.5rem; + } + +} + +.warningBanner { + @include InterFont; + font-size: 0.875rem; + font-weight: 400; + line-height: 140%; + color: $light-grey; + margin-bottom: 3rem; +} + +.section { + padding: 1.25rem 0; + + &:first-child { + padding-top: 0; + } +} + +.sectionHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; + + h4 { + @include InterFont; + font-size: 1rem; + font-weight: 600; + line-height: 140%; + margin: 0; + } +} + +.sectionDivider { + border: 0; + border-top: 1px solid rgba(255, 255, 255, 0.08); + margin: 0.5rem 0; +} + +.certTable { + width: 100%; + border-collapse: collapse; + @include InterFont; + font-size: 0.8125rem; + + th { + text-align: left; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + color: $light-grey; + padding: 8px 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + white-space: nowrap; + } + + td { + padding: 10px 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + color: $white; + word-break: break-all; + vertical-align: middle; + } + + tr:last-child td { + border-bottom: none; + } +} + +.emptyMessage { + @include InterFont; + font-size: 0.875rem; + font-weight: 400; + line-height: 140%; + color: $light-grey; + padding: 0.75rem 0; +} + +.deleteButton { + background: none; + border: none; + cursor: pointer; + padding: 6px; + 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; + } +} + +@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; + margin-top: 0.5rem; + border-radius: 4px; +} + +.noPermission { + pointer-events: none; + opacity: 0.6; +} + +.fileInputField { + padding: 0 1rem; + width: 100%; + + > div { + height: auto; + text-align: left; + white-space: normal; + overflow: visible; + + > div { + float: none !important; + margin-top: 0; + margin-right: 0; + } + + > span, + > a { + display: block; + float: none !important; + margin-top: 4px; + font-size: 0.75rem; + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + } + } +} + +.radiobuttonWrapper { + display: flex; + justify-content: flex-start; + align-items: center; + padding: 0 1rem; + gap: 1.25rem; + box-sizing: border-box; +} + +.radioOption { + display: flex; + align-items: center; + gap: 0.25rem; + cursor: pointer; + white-space: nowrap; + font-size: 0.875rem; +} + +.toolbarButton { + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + opacity: 0.8; + transition: opacity 0.15s ease; + + &:hover { + opacity: 1; + } + + svg { + fill: $white; + } +} + +.tooltipWrapper { + display: inline-block; + cursor: help; +} + +.addButtonLabel { + display: flex; + gap: 0.5rem; + align-items: center; +} diff --git a/src/dashboard/Settings/SocialAuth.react.js b/src/dashboard/Settings/SocialAuth.react.js index ed1388b8a7..2953ccfd46 100644 --- a/src/dashboard/Settings/SocialAuth.react.js +++ b/src/dashboard/Settings/SocialAuth.react.js @@ -8,6 +8,7 @@ import FlowView from 'components/FlowView/FlowView.react'; import layoutStyles from '../CustomParseOptions/CustomParseOptions.scss'; import fbStyles from './SocialAuth.scss'; import EmptyGhostState from 'components/EmptyGhostState/EmptyGhostState.react'; +import { amplitudeLogEvent } from 'lib/amplitudeEvents'; import Label from 'components/Label/Label.react'; import Field from 'components/Field/Field.react'; @@ -682,7 +683,17 @@ class SocialAuth extends DashboardView { const payload = buildOauthPayload(oauth); return this.context.updateOauth(payload); }} - afterSave={({ resetFields }) => { + afterSave={({ fields, resetFields }) => { + const prevOauth = this.state.initialFields.oauth || {}; + const currOauth = fields.oauth || {}; + const prevPayload = buildOauthPayload(prevOauth); + const currPayload = buildOauthPayload(currOauth); + if (currPayload.facebook && JSON.stringify(currPayload.facebook) !== JSON.stringify(prevPayload.facebook)) { + amplitudeLogEvent('facebook login configured'); + } + if (currPayload.twitter && JSON.stringify(currPayload.twitter) !== JSON.stringify(prevPayload.twitter)) { + amplitudeLogEvent('twitter login configured'); + } this.loadData(); this._lastComputedDirty = false; this.setState({ isDirty: false }); diff --git a/src/lib/ParseApp.js b/src/lib/ParseApp.js index 6ff34cfa8a..7a64a239dd 100644 --- a/src/lib/ParseApp.js +++ b/src/lib/ParseApp.js @@ -2127,4 +2127,87 @@ export default class ParseApp { throw err.response && err.response.data && err.response.data.error ? err.response.data.error : err; } } + + async getPushIOSConfig() { + try { + return ( + await axios.get( + // eslint-disable-next-line no-undef + `${b4aSettings.BACK4APP_API_PATH}/parse-app/${this.slug}/push/list`, + { withCredentials: true } + ) + ).data; + } catch (err) { + throw err.response && err.response.data && err.response.data.error ? err.response.data.error : err; + } + } + + async uploadP8AuthKey(file, keyId, teamId, bundleId, deviceType, production) { + try { + const formData = new FormData(); + formData.append('certificate', file); + formData.append('keyId', keyId); + formData.append('teamId', teamId); + formData.append('bundleId', bundleId); + formData.append('deviceType', deviceType); + formData.append('production', production); + return ( + await axios.post( + // eslint-disable-next-line no-undef + `${b4aSettings.BACK4APP_API_PATH}/parse-app/${this.slug}/push/auth`, + formData, + { withCredentials: true } + ) + ).data; + } catch (err) { + throw err.response && err.response.data && err.response.data.error ? err.response.data.error : err; + } + } + + async uploadP12Certificate(file, deviceType, production) { + try { + const formData = new FormData(); + formData.append('certificate', file); + formData.append('deviceType', deviceType); + formData.append('production', production); + return ( + await axios.post( + // eslint-disable-next-line no-undef + `${b4aSettings.BACK4APP_API_PATH}/parse-app/${this.slug}/push/cert`, + formData, + { withCredentials: true } + ) + ).data; + } catch (err) { + throw err.response && err.response.data && err.response.data.error ? err.response.data.error : err; + } + } + + async deleteP8AuthKey(certificateId, dataType) { + try { + return ( + await axios.delete( + // eslint-disable-next-line no-undef + `${b4aSettings.BACK4APP_API_PATH}/parse-app/${this.slug}/push/authkey`, + { data: JSON.stringify({ certificateId, dataType }), headers: { 'Content-Type': 'text/plain' }, withCredentials: true } + ) + ).data; + } catch (err) { + throw err.response && err.response.data && err.response.data.error ? err.response.data.error : err; + } + } + + async deleteP12Certificate(certificateId, dataType) { + try { + return ( + await axios.delete( + // eslint-disable-next-line no-undef + `${b4aSettings.BACK4APP_API_PATH}/parse-app/${this.slug}/push/ios`, + { data: JSON.stringify({ certificateId, dataType }), headers: { 'Content-Type': 'text/plain' }, 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 81cc2f9832..688d522b3c 100644 --- a/src/lib/serverInfo.js +++ b/src/lib/serverInfo.js @@ -26,9 +26,14 @@ export const ALWAYS_ALLOWED_ROUTES = [ 'environment-variables', 'social-auth', 'Social Auth', + 'Notifications', 'Notification', 'notification', - 'push', + 'notification/email', + 'notification/email/verification', + 'notification/email/password-reset', + 'push/android-settings', + 'push/ios-settings', 'server-url-live-query', 'Server URL & Live Query', ];