diff --git a/src/dashboard/Dashboard.js b/src/dashboard/Dashboard.js index b44e5163b..54a3d1f19 100644 --- a/src/dashboard/Dashboard.js +++ b/src/dashboard/Dashboard.js @@ -71,6 +71,7 @@ import Deployments from './Deployments/Deployments.react'; import DeploymentDetails from './Deployments/DeploymentDetails.react'; import { useAppPageTracking } from './instrument'; import DomainSettings from './DomainSettings/DomainSettings.react'; +import ServerURLLiveQuery from './ServerURLLiveQuery/ServerURLLiveQuery.react'; import CustomParseOptions from './CustomParseOptions/CustomParseOptions.react'; const LazyGraphQLConsole = lazy(() => import('./Data/ApiConsole/GraphQLConsole.react')); @@ -82,6 +83,7 @@ const LazyDatabaseProfile = lazy(() => import('./DatabaseProfiler/DatabaseProfil const LazyEmailVerification = lazy(() => import('./Notification/EmailVerification.react')); const LazyEmailPasswordReset = lazy(() => import('./Notification/EmailPasswordReset.react')); + async function fetchHubUser() { try { // eslint-disable-next-line no-undef @@ -511,6 +513,7 @@ class Dashboard extends React.Component { } /> } /> } /> + } /> } /> } /> diff --git a/src/dashboard/DashboardView.react.js b/src/dashboard/DashboardView.react.js index 18d9e541b..1f673fd9d 100644 --- a/src/dashboard/DashboardView.react.js +++ b/src/dashboard/DashboardView.react.js @@ -266,6 +266,11 @@ export default class DashboardView extends React.Component { link: '/domain-settings', }); + settingsSections.push({ + name: 'Server URL & Live Query', + link: '/server-url-live-query', + }); + settingsSections.push({ name: 'Environment Variables', link: '/settings/environment-variables', diff --git a/src/dashboard/ServerURLLiveQuery/ServerURLLiveQuery.react.js b/src/dashboard/ServerURLLiveQuery/ServerURLLiveQuery.react.js new file mode 100644 index 000000000..49eb02c9d --- /dev/null +++ b/src/dashboard/ServerURLLiveQuery/ServerURLLiveQuery.react.js @@ -0,0 +1,586 @@ +/* + * Copyright (c) 2016-present, Parse, LLC + * All rights reserved. + * + * This source code is licensed under the license found in the LICENSE file in + * the root directory of this source tree. + */ +import AccountManager from 'lib/AccountManager'; +import React from 'react'; +import Toolbar from 'components/Toolbar/Toolbar.react'; +import { withRouter } from 'lib/withRouter'; +import DashboardView from 'dashboard/DashboardView.react'; +import B4aLoaderContainer from 'components/B4aLoaderContainer/B4aLoaderContainer.react'; +import FlowView from 'components/FlowView/FlowView.react'; +import styles from './ServerURLLiveQuery.scss'; +import EmptyGhostState from 'components/EmptyGhostState/EmptyGhostState.react'; +import Button from 'components/Button/Button.react'; +import B4aToggle from 'components/Toggle/B4aToggle.react'; +import Label from 'components/Label/Label.react'; +import Field from 'components/Field/Field.react'; +import TextInput from 'components/TextInput/TextInput.react'; +import Fieldset from 'components/Fieldset/Fieldset.react'; +import B4aModal from 'components/B4aModal/B4aModal.react'; +import { amplitudeLogEvent } from 'lib/amplitudeEvents'; +import renderFlowFooterChanges from 'lib/renderFlowFooterChanges'; + +const SIX_MONTHS_MS = 6 * 30 * 24 * 60 * 60 * 1000; + +const FOOTER_FIELD_OPTIONS = { + activated: { friendlyName: 'Back4App subdomain', type: 'boolean' }, + subdomainName: { friendlyName: 'subdomain name', showTo: true }, + currentDomain: { friendlyName: 'domain', showTo: true }, + statusLiveQuery: { friendlyName: 'Live Query', type: 'boolean' }, + schemasChoose: { friendlyName: 'Live Query classes' }, +}; + +@withRouter +class ServerURLLiveQuery extends DashboardView { + constructor() { + super(); + this.section = 'App Settings'; + this.subsection = 'Server URL & Live Query'; + this.state = { + isLoading: true, + loadingError: null, + + isUserVerified: false, + hasPermission: true, + + isActivated: false, + currentSubdomain: '', + availableDomains: ['b4a.io'], + schema: [], + activatedLiveQuery: {}, + + initialFields: { + activated: false, + subdomainName: '', + currentDomain: 'b4a.io', + statusLiveQuery: false, + schemasChoose: {}, + }, + + modal: null, + }; + + this._flowViewRef = React.createRef(); + this.unblock = null; + this._onBeforeUnload = null; + } + + _hasUnsavedChanges() { + if (this._flowViewRef.current) { + const { changes, saveState } = this._flowViewRef.current.state; + return Object.keys(changes || {}).length > 0 && saveState !== 'SAVING'; + } + return false; + } + + componentDidMount() { + super.componentDidMount(); + this.loadData(); + + this._onBeforeUnload = (e) => { + if (this._hasUnsavedChanges()) { + e.preventDefault(); + e.returnValue = ''; + return ''; + } + }; + window.addEventListener('beforeunload', this._onBeforeUnload); + + if (this.props.navigator && typeof this.props.navigator.block === 'function') { + this.unblock = this.props.navigator.block(tx => { + if (this._hasUnsavedChanges()) { + const unblock = this.unblock && this.unblock.bind(this); + const autoUnblockingTx = { + ...tx, + retry() { + if (unblock) { + unblock(); + } + tx.retry(); + }, + }; + + const modal = ( + + this.setState({ modal: null })} + /> + { + this.setState({ modal: null }); + autoUnblockingTx.retry(); + }} + /> + + } + /> + ); + this.setState({ modal }); + } else { + if (this.unblock) { + this.unblock(); + } + tx.retry(); + } + }); + } + } + + componentWillUnmount() { + if (this.unblock) { + this.unblock(); + } + window.removeEventListener('beforeunload', this._onBeforeUnload); + } + + async loadData() { + try { + const response = await this.context.getWebHostForLiveQuery(); + await this.processSettings(response); + } catch (error) { + const message = typeof error === 'string' + ? error + : (error && error.message) || 'Failed to load settings'; + this.setState({ loadingError: message }); + } finally { + this.setState({ isLoading: false }); + } + } + + async processSettings(response) { + const { settings, schema: rawSchema, createdAt } = response; + + const schema = Array.isArray(rawSchema) + ? rawSchema.map(s => ({ _id: s._id })) + : []; + + let subdomainName = ''; + let currentSubdomain = ''; + let currentDomain = 'b4a.io'; + let activated = false; + let isActivated = false; + let isUserVerified = false; + const schemasChoose = {}; + let activatedLiveQuery = {}; + let statusLiveQuery = false; + + if (settings.subdomain) { + const subDomainName = settings.subdomain; + currentSubdomain = subDomainName; + subdomainName = subDomainName.replace(/\.(b4a|back4app|b4app)\.(app|io)$/, ''); + currentDomain = this.updateAvailableDomains(subDomainName); + } else { + const appName = settings.appName || ''; + subdomainName = appName.replace(/[^A-Z0-9]+/ig, '').toLowerCase(); + } + + activated = !!settings.activated; + isActivated = activated && !!settings.subdomain; + + if (settings.liveQuery) { + activatedLiveQuery = settings.liveQuery; + if (settings.liveQuery.statusLiveQuery) { + statusLiveQuery = settings.liveQuery.statusLiveQuery; + } + if (settings.liveQuery.schemasChoose) { + Object.assign(schemasChoose, settings.liveQuery.schemasChoose); + } + } + + const hasPermission = !settings.featuresPermission || + settings.featuresPermission.webHostLiveQuery === 'Write'; + + if ((settings.activated && settings.subdomain) || + (createdAt && ((new Date() - new Date(createdAt)) > SIX_MONTHS_MS))) { + isUserVerified = true; + } + + if (!isUserVerified) { + try { + const plan = await this.context.getAppPlanData(); + if (plan && plan.planName && + plan.planName.indexOf('Free') < 0 && + plan.planName.indexOf('Public') < 0) { + isUserVerified = true; + } else { + const currentUser = AccountManager.currentUser(); + if (currentUser && currentUser.verification && currentUser.verification.cardValidation) { + isUserVerified = true; + } + } + } catch (planError) { + const currentUser = AccountManager.currentUser(); + if (currentUser && currentUser.verification && currentUser.verification.cardValidation) { + isUserVerified = true; + } + } + } + + this.setState({ + isUserVerified, + hasPermission, + isActivated, + currentSubdomain, + schema, + activatedLiveQuery, + initialFields: { + activated, + subdomainName, + currentDomain, + statusLiveQuery, + schemasChoose, + }, + }); + } + + updateAvailableDomains(appDomain) { + const domain = appDomain.substring( + appDomain.lastIndexOf('.', appDomain.lastIndexOf('.') - 1) + 1 + ); + const availableDomains = this.state.availableDomains.indexOf(domain) === -1 + ? [...this.state.availableDomains, domain] + : this.state.availableDomains; + this.setState({ availableDomains }); + return domain; + } + + renderToolbar() { + return ( + + ); + } + + filterVisibleChanges(changes) { + const { initialFields } = this.state; + const filtered = { ...changes }; + const activated = changes.activated !== undefined + ? changes.activated + : initialFields.activated; + + if (!activated) { + delete filtered.subdomainName; + delete filtered.currentDomain; + delete filtered.statusLiveQuery; + delete filtered.schemasChoose; + } else { + const statusLiveQuery = changes.statusLiveQuery !== undefined + ? changes.statusLiveQuery + : initialFields.statusLiveQuery; + if (!statusLiveQuery) { + delete filtered.schemasChoose; + } + } + return filtered; + } + + renderForm({ fields, setField }) { + const toggleClass = (classId) => { + const schemasChoose = { ...fields.schemasChoose }; + schemasChoose[classId] = !schemasChoose[classId]; + setField('schemasChoose', schemasChoose); + }; + + return ( + + + Server URL and Live Query + + In this section, you can enable a custom Server URL that can be used for real-time database. + + + + + } + input={ + + setField('activated', value)} + type={B4aToggle.Types.YES_NO} + /> + + } + theme={Field.Theme.BLUE} + /> + {fields.activated && ( + + } + input={ + + + setField('subdomainName', name)} + placeholder="yourapp" + /> + . + + + {this.state.availableDomains.length > 1 ? ( + setField('currentDomain', e.target.value)} + style={{ + background: 'transparent', + border: 'none', + color: '#fff', + fontSize: '14px', + padding: '0.25rem', + outline: 'none', + }} + > + {this.state.availableDomains.map((domain) => ( + + {domain} + + ))} + + ) : ( + + {fields.currentDomain} + + )} + + + } + theme={Field.Theme.BLUE} + /> + )} + + + {fields.activated && ( + <> + + + + } + input={ + + setField('statusLiveQuery', value)} + type={B4aToggle.Types.YES_NO} + /> + + } + theme={Field.Theme.BLUE} + /> + {fields.statusLiveQuery && this.state.schema.length > 0 && ( + + } + input={ + + {this.state.schema.map((cls) => ( + + toggleClass(cls._id)} + /> + {cls._id} + + ))} + + } + theme={Field.Theme.BLUE} + /> + )} + + > + )} + + + ); + } + + renderContent() { + const toolbar = this.renderToolbar(); + const loading = this.state.isLoading; + + let content = null; + if (loading) { + content = null; + } else if (this.state.loadingError) { + content = ( + + + + ); + } else if (!this.state.isUserVerified) { + content = ( + + + Server URL and Live Query + + In this section, you can enable a custom Server URL that can be used for real-time database. + + + + } + input={ + + + + + + } + theme={Field.Theme.BLUE} + /> + + + + ); + } else { + const { initialFields, hasPermission } = this.state; + + content = ( + + Object.keys(this.filterVisibleChanges(changes)).length > 0} + validate={() => { + if (!hasPermission) { + return 'use default'; + } + return ''; + }} + defaultFooterMessage={You don't have permission to edit this feature.} + hideButtonsOnDefaultMessage={true} + onSubmit={({ fields }) => { + const subdomainName = fields.subdomainName + ? fields.subdomainName.toLowerCase() + : fields.subdomainName; + + return this.context.enableHostForLiveQuery({ + currentSubdomain: this.state.currentSubdomain, + subdomainName: subdomainName + '.' + fields.currentDomain, + activated: fields.activated, + }).then(() => { + amplitudeLogEvent('Server URL configured'); + + let schemasChoose = fields.schemasChoose; + if (!fields.statusLiveQuery) { + schemasChoose = {}; + } + + const hasExistingLiveQuery = this.state.activatedLiveQuery.statusLiveQuery !== undefined; + if (fields.statusLiveQuery || hasExistingLiveQuery) { + return this.context.setLiveQuery({ + statusLiveQuery: fields.statusLiveQuery, + schemasChoose, + }).then(() => amplitudeLogEvent('Live Query configured')); + } + }).catch(err => { + const message = typeof err === 'string' ? err : (err && err.message) || 'An error occurred'; + throw { message }; + }); + }} + afterSave={({ fields, resetFields }) => { + const savedSubdomain = fields.subdomainName + ? fields.subdomainName.toLowerCase() + '.' + fields.currentDomain + : this.state.currentSubdomain; + + this.setState({ + currentSubdomain: fields.activated ? savedSubdomain : this.state.currentSubdomain, + isActivated: fields.activated && !!savedSubdomain, + activatedLiveQuery: fields.statusLiveQuery + ? { ...this.state.activatedLiveQuery, statusLiveQuery: fields.statusLiveQuery, schemasChoose: fields.schemasChoose } + : this.state.activatedLiveQuery, + initialFields: { ...fields }, + }); + resetFields(); + }} + footerContents={({ changes }) => { + const visibleChanges = this.filterVisibleChanges(changes); + return renderFlowFooterChanges(visibleChanges, initialFields, FOOTER_FIELD_OPTIONS); + }} + renderForm={this.renderForm.bind(this)} + /> + + ); + } + + return ( + + + + {content} + + + {toolbar} + {this.state.modal} + + ); + } +} + +export default ServerURLLiveQuery; diff --git a/src/dashboard/ServerURLLiveQuery/ServerURLLiveQuery.scss b/src/dashboard/ServerURLLiveQuery/ServerURLLiveQuery.scss new file mode 100644 index 000000000..b6b3d1bf2 --- /dev/null +++ b/src/dashboard/ServerURLLiveQuery/ServerURLLiveQuery.scss @@ -0,0 +1,94 @@ +@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; + & .ghostMessage { + padding-top: 3rem; + } + & .mainContent { + height: calc(#{$content-max-height} - #{$toolbar-height}); + position: relative; + overflow: auto; + } +} + +.formWrapper { + width: 100%; + padding: 0; + max-width: 800px; + margin: 0 auto; + margin-top: 5rem; +} + +@media only screen and (max-width: 1200px) { + .formWrapper { + 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%; + } + + .fieldHr { + margin: 4px 0; + border: 0; + } +} + +.note { + color: $light-grey; + font-size: 12px; + font-weight: 400; + line-height: 140%; + margin-top: 0.5rem; +} + +.classesList { + display: flex; + flex-direction: column; + width: 100%; + padding: 0.25rem 1rem; + + & > .classItem { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0; + font-size: 14px; + max-width: 100%; + + input[type='checkbox'] { + accent-color: $cta-green; + cursor: pointer; + width: 16px; + height: 16px; + flex-shrink: 0; + } + + span { + user-select: none; + color: $white; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } +} diff --git a/src/lib/ParseApp.js b/src/lib/ParseApp.js index 03804ca91..b307b05e9 100644 --- a/src/lib/ParseApp.js +++ b/src/lib/ParseApp.js @@ -1406,6 +1406,26 @@ export default class ParseApp { }) } + async getWebHostForLiveQuery() { + try { + // eslint-disable-next-line no-undef + const path = `${b4aSettings.BACK4APP_API_PATH}/parse-app/${this.slug}/webhostapp`; + return (await axios.get(path, { withCredentials: true })).data; + } catch (err) { + throw err.response && err.response.data && err.response.data.error ? err.response.data.error : err; + } + } + + async enableHostForLiveQuery(hostSettings) { + try { + // eslint-disable-next-line no-undef + const path = `${b4aSettings.BACK4APP_API_PATH}/parse-app/${this.slug}/webhostapp`; + return (await axios.post(path, { hostSettings }, { withCredentials: true })).data; + } catch (err) { + throw err.response && err.response.data && err.response.data.error ? err.response.data.error : err; + } + } + createTextIndexes() { return axios.post(`/parse-app/${this.slug}/index`, { index: { '$**': 'text' } }).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 4958d6295..a27bbf4e3 100644 --- a/src/lib/serverInfo.js +++ b/src/lib/serverInfo.js @@ -28,6 +28,8 @@ export const ALWAYS_ALLOWED_ROUTES = [ 'Social Auth', 'Notification', 'notification', + 'server-url-live-query', + 'Server URL & Live Query', ]; export const canAccess = (serverInfo, route) => {