diff --git a/packages/auth/README.md b/packages/auth/README.md index bbe704026a..4c6a474b5b 100644 --- a/packages/auth/README.md +++ b/packages/auth/README.md @@ -1 +1,12 @@ -# Budibase Authentication Library \ No newline at end of file +# Budibase Core backend library + +This library contains core functionality, like auth and security features +which are shared between backend services. + +#### Note about top level JS files +For the purposes of being able to do say `require("@budibase/auth/permissions")` we need to +specify the exports at the top-level of the module. + +For these files they should be limited to a single `require` of the file that should +be exported and then a single `module.exports = ...` to export the file in +commonJS. \ No newline at end of file diff --git a/packages/auth/db.js b/packages/auth/db.js new file mode 100644 index 0000000000..4b03ec36cc --- /dev/null +++ b/packages/auth/db.js @@ -0,0 +1 @@ +module.exports = require("./src/db/utils") diff --git a/packages/auth/package.json b/packages/auth/package.json index 56b904c966..42bc76f3f4 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -11,6 +11,7 @@ "ioredis": "^4.27.1", "jsonwebtoken": "^8.5.1", "koa-passport": "^4.1.4", + "lodash": "^4.17.21", "node-fetch": "^2.6.1", "passport-google-auth": "^1.0.2", "passport-google-oauth": "^2.0.0", diff --git a/packages/auth/permissions.js b/packages/auth/permissions.js new file mode 100644 index 0000000000..42f37c9c7e --- /dev/null +++ b/packages/auth/permissions.js @@ -0,0 +1 @@ +module.exports = require("./src/security/permissions") diff --git a/packages/auth/redis.js b/packages/auth/redis.js new file mode 100644 index 0000000000..0a9dc91881 --- /dev/null +++ b/packages/auth/redis.js @@ -0,0 +1,4 @@ +module.exports = { + Client: require("./src/redis"), + utils: require("./src/redis/utils"), +} diff --git a/packages/auth/roles.js b/packages/auth/roles.js new file mode 100644 index 0000000000..158bcdb6b8 --- /dev/null +++ b/packages/auth/roles.js @@ -0,0 +1 @@ +module.exports = require("./src/security/roles") diff --git a/packages/auth/src/constants.js b/packages/auth/src/constants.js index 8ca05066c9..230c80b609 100644 --- a/packages/auth/src/constants.js +++ b/packages/auth/src/constants.js @@ -14,3 +14,10 @@ exports.GlobalRoles = { BUILDER: "builder", GROUP_MANAGER: "group_manager", } + +exports.Configs = { + SETTINGS: "settings", + ACCOUNT: "account", + SMTP: "smtp", + GOOGLE: "google", +} diff --git a/packages/auth/src/db/Replication.js b/packages/auth/src/db/Replication.js new file mode 100644 index 0000000000..931bc3d496 --- /dev/null +++ b/packages/auth/src/db/Replication.js @@ -0,0 +1,79 @@ +const { getDB } = require(".") + +class Replication { + /** + * + * @param {String} source - the DB you want to replicate or rollback to + * @param {String} target - the DB you want to replicate to, or rollback from + */ + constructor({ source, target }) { + this.source = getDB(source) + this.target = getDB(target) + } + + promisify(operation, opts = {}) { + return new Promise(resolve => { + operation(this.target, opts) + .on("denied", function (err) { + // a document failed to replicate (e.g. due to permissions) + throw new Error(`Denied: Document failed to replicate ${err}`) + }) + .on("complete", function (info) { + return resolve(info) + }) + .on("error", function (err) { + throw new Error(`Replication Error: ${err}`) + }) + }) + } + + /** + * Two way replication operation, intended to be promise based. + * @param {Object} opts - PouchDB replication options + */ + sync(opts = {}) { + this.replication = this.promisify(this.source.sync, opts) + return this.replication + } + + /** + * One way replication operation, intended to be promise based. + * @param {Object} opts - PouchDB replication options + */ + replicate(opts = {}) { + this.replication = this.promisify(this.source.replicate.to, opts) + return this.replication + } + + /** + * Set up an ongoing live sync between 2 CouchDB databases. + * @param {Object} opts - PouchDB replication options + */ + subscribe(opts = {}) { + this.replication = this.source.replicate + .to(this.target, { + live: true, + retry: true, + ...opts, + }) + .on("error", function (err) { + throw new Error(`Replication Error: ${err}`) + }) + } + + /** + * Rollback the target DB back to the state of the source DB + */ + async rollback() { + await this.target.destroy() + // Recreate the DB again + this.target = getDB(this.target.name) + await this.replicate() + } + + cancel() { + this.replication.cancel() + } +} + +module.exports = Replication diff --git a/packages/auth/src/db/index.js b/packages/auth/src/db/index.js index f94fe4afea..163364dbf3 100644 --- a/packages/auth/src/db/index.js +++ b/packages/auth/src/db/index.js @@ -7,3 +7,7 @@ module.exports.setDB = pouch => { module.exports.getDB = dbName => { return new Pouch(dbName) } + +module.exports.getCouch = () => { + return Pouch +} diff --git a/packages/auth/src/db/utils.js b/packages/auth/src/db/utils.js index 021ccee646..91a682d859 100644 --- a/packages/auth/src/db/utils.js +++ b/packages/auth/src/db/utils.js @@ -1,4 +1,9 @@ const { newid } = require("../hashing") +const Replication = require("./Replication") +const { getCouch } = require("./index") + +const UNICODE_MAX = "\ufff0" +const SEPARATOR = "_" exports.ViewNames = { USER_BY_EMAIL: "by_email", @@ -8,23 +13,50 @@ exports.StaticDatabases = { GLOBAL: { name: "global-db", }, + DEPLOYMENTS: { + name: "deployments", + }, } const DocumentTypes = { USER: "us", - APP: "app", GROUP: "group", CONFIG: "config", TEMPLATE: "template", + APP: "app", + APP_DEV: "app_dev", + APP_METADATA: "app_metadata", + ROLE: "role", } exports.DocumentTypes = DocumentTypes - -const UNICODE_MAX = "\ufff0" -const SEPARATOR = "_" - +exports.APP_PREFIX = DocumentTypes.APP + SEPARATOR +exports.APP_DEV_PREFIX = DocumentTypes.APP_DEV + SEPARATOR exports.SEPARATOR = SEPARATOR +/** + * If creating DB allDocs/query params with only a single top level ID this can be used, this + * is usually the case as most of our docs are top level e.g. tables, automations, users and so on. + * More complex cases such as link docs and rows which have multiple levels of IDs that their + * ID consists of need their own functions to build the allDocs parameters. + * @param {string} docType The type of document which input params are being built for, e.g. user, + * link, app, table and so on. + * @param {string|null} docId The ID of the document minus its type - this is only needed if looking + * for a singular document. + * @param {object} otherProps Add any other properties onto the request, e.g. include_docs. + * @returns {object} Parameters which can then be used with an allDocs request. + */ +function getDocParams(docType, docId = null, otherProps = {}) { + if (docId == null) { + docId = "" + } + return { + ...otherProps, + startkey: `${docType}${SEPARATOR}${docId}`, + endkey: `${docType}${SEPARATOR}${docId}${UNICODE_MAX}`, + } +} + /** * Generates a new group ID. * @returns {string} The new group ID which the group doc can be stored under. @@ -94,6 +126,65 @@ exports.getTemplateParams = (ownerId, templateId, otherProps = {}) => { } } +/** + * Generates a new role ID. + * @returns {string} The new role ID which the role doc can be stored under. + */ +exports.generateRoleID = id => { + return `${DocumentTypes.ROLE}${SEPARATOR}${id || newid()}` +} + +/** + * Gets parameters for retrieving a role, this is a utility function for the getDocParams function. + */ +exports.getRoleParams = (roleId = null, otherProps = {}) => { + return getDocParams(DocumentTypes.ROLE, roleId, otherProps) +} + +/** + * Convert a development app ID to a deployed app ID. + */ +exports.getDeployedAppID = appId => { + // if dev, convert it + if (appId.startsWith(exports.APP_DEV_PREFIX)) { + const id = appId.split(exports.APP_DEV_PREFIX)[1] + return `${exports.APP_PREFIX}${id}` + } + return appId +} + +/** + * Lots of different points in the system need to find the full list of apps, this will + * enumerate the entire CouchDB cluster and get the list of databases (every app). + * NOTE: this operation is fine in self hosting, but cannot be used when hosting many + * different users/companies apps as there is no security around it - all apps are returned. + * @return {Promise} returns the app information document stored in each app database. + */ +exports.getAllApps = async (devApps = false) => { + const CouchDB = getCouch() + let allDbs = await CouchDB.allDbs() + const appDbNames = allDbs.filter(dbName => + dbName.startsWith(exports.APP_PREFIX) + ) + const appPromises = appDbNames.map(db => + new CouchDB(db).get(DocumentTypes.APP_METADATA) + ) + if (appPromises.length === 0) { + return [] + } else { + const response = await Promise.allSettled(appPromises) + const apps = response + .filter(result => result.status === "fulfilled") + .map(({ value }) => value) + return apps.filter(app => { + if (devApps) { + return app.appId.startsWith(exports.APP_DEV_PREFIX) + } + return !app.appId.startsWith(exports.APP_DEV_PREFIX) + }) + } +} + /** * Generates a new configuration ID. * @returns {string} The new configuration ID which the config doc can be stored under. @@ -165,6 +256,7 @@ async function getScopedConfig(db, params) { return configDoc && configDoc.config ? configDoc.config : configDoc } +exports.Replication = Replication exports.getScopedConfig = getScopedConfig exports.generateConfigID = generateConfigID exports.getConfigParams = getConfigParams diff --git a/packages/auth/src/objectStore/index.js b/packages/auth/src/objectStore/index.js index a78253f90a..c6d1c3e2ce 100644 --- a/packages/auth/src/objectStore/index.js +++ b/packages/auth/src/objectStore/index.js @@ -10,6 +10,7 @@ const fs = require("fs") const env = require("../environment") const { budibaseTempDir, ObjectStoreBuckets } = require("./utils") const { v4 } = require("uuid") +const { APP_PREFIX, APP_DEV_PREFIX } = require("../db/utils") const streamPipeline = promisify(stream.pipeline) // use this as a temporary store of buckets that are being created @@ -28,6 +29,16 @@ const STRING_CONTENT_TYPES = [ CONTENT_TYPE_MAP.js, ] +// does normal sanitization and then swaps dev apps to apps +function sanitizeKey(input) { + return sanitize(sanitizeBucket(input)).replace(/\\/g, "/") +} + +// simply handles the dev app to app conversion +function sanitizeBucket(input) { + return input.replace(new RegExp(APP_DEV_PREFIX, "g"), APP_PREFIX) +} + function publicPolicy(bucketName) { return { Version: "2012-10-17", @@ -61,7 +72,7 @@ exports.ObjectStore = bucket => { s3ForcePathStyle: true, signatureVersion: "v4", params: { - Bucket: bucket, + Bucket: sanitizeBucket(bucket), }, } if (env.MINIO_URL) { @@ -75,6 +86,7 @@ exports.ObjectStore = bucket => { * if it does not exist then it will create it. */ exports.makeSureBucketExists = async (client, bucketName) => { + bucketName = sanitizeBucket(bucketName) try { await client .headBucket({ @@ -114,16 +126,22 @@ exports.makeSureBucketExists = async (client, bucketName) => { * Uploads the contents of a file given the required parameters, useful when * temp files in use (for example file uploaded as an attachment). */ -exports.upload = async ({ bucket, filename, path, type, metadata }) => { +exports.upload = async ({ + bucket: bucketName, + filename, + path, + type, + metadata, +}) => { const extension = [...filename.split(".")].pop() const fileBytes = fs.readFileSync(path) - const objectStore = exports.ObjectStore(bucket) - await exports.makeSureBucketExists(objectStore, bucket) + const objectStore = exports.ObjectStore(bucketName) + await exports.makeSureBucketExists(objectStore, bucketName) const config = { // windows file paths need to be converted to forward slashes for s3 - Key: sanitize(filename).replace(/\\/g, "/"), + Key: sanitizeKey(filename), Body: fileBytes, ContentType: type || CONTENT_TYPE_MAP[extension.toLowerCase()], } @@ -137,13 +155,13 @@ exports.upload = async ({ bucket, filename, path, type, metadata }) => { * Similar to the upload function but can be used to send a file stream * through to the object store. */ -exports.streamUpload = async (bucket, filename, stream) => { - const objectStore = exports.ObjectStore(bucket) - await exports.makeSureBucketExists(objectStore, bucket) +exports.streamUpload = async (bucketName, filename, stream) => { + const objectStore = exports.ObjectStore(bucketName) + await exports.makeSureBucketExists(objectStore, bucketName) const params = { - Bucket: bucket, - Key: sanitize(filename).replace(/\\/g, "/"), + Bucket: sanitizeBucket(bucketName), + Key: sanitizeKey(filename), Body: stream, } return objectStore.upload(params).promise() @@ -153,11 +171,11 @@ exports.streamUpload = async (bucket, filename, stream) => { * retrieves the contents of a file from the object store, if it is a known content type it * will be converted, otherwise it will be returned as a buffer stream. */ -exports.retrieve = async (bucket, filepath) => { - const objectStore = exports.ObjectStore(bucket) +exports.retrieve = async (bucketName, filepath) => { + const objectStore = exports.ObjectStore(bucketName) const params = { - Bucket: bucket, - Key: sanitize(filepath).replace(/\\/g, "/"), + Bucket: sanitizeBucket(bucketName), + Key: sanitizeKey(filepath), } const response = await objectStore.getObject(params).promise() // currently these are all strings @@ -171,17 +189,21 @@ exports.retrieve = async (bucket, filepath) => { /** * Same as retrieval function but puts to a temporary file. */ -exports.retrieveToTmp = async (bucket, filepath) => { - const data = await exports.retrieve(bucket, filepath) +exports.retrieveToTmp = async (bucketName, filepath) => { + bucketName = sanitizeBucket(bucketName) + filepath = sanitizeKey(filepath) + const data = await exports.retrieve(bucketName, filepath) const outputPath = join(budibaseTempDir(), v4()) fs.writeFileSync(outputPath, data) return outputPath } -exports.deleteFolder = async (bucket, folder) => { - const client = exports.ObjectStore(bucket) +exports.deleteFolder = async (bucketName, folder) => { + bucketName = sanitizeBucket(bucketName) + folder = sanitizeKey(folder) + const client = exports.ObjectStore(bucketName) const listParams = { - Bucket: bucket, + Bucket: bucketName, Prefix: folder, } @@ -190,7 +212,7 @@ exports.deleteFolder = async (bucket, folder) => { return } const deleteParams = { - Bucket: bucket, + Bucket: bucketName, Delete: { Objects: [], }, @@ -203,28 +225,31 @@ exports.deleteFolder = async (bucket, folder) => { response = await client.deleteObjects(deleteParams).promise() // can only empty 1000 items at once if (response.Deleted.length === 1000) { - return exports.deleteFolder(bucket, folder) + return exports.deleteFolder(bucketName, folder) } } -exports.uploadDirectory = async (bucket, localPath, bucketPath) => { +exports.uploadDirectory = async (bucketName, localPath, bucketPath) => { + bucketName = sanitizeBucket(bucketName) let uploads = [] const files = fs.readdirSync(localPath, { withFileTypes: true }) for (let file of files) { - const path = join(bucketPath, file.name) + const path = sanitizeKey(join(bucketPath, file.name)) const local = join(localPath, file.name) if (file.isDirectory()) { - uploads.push(exports.uploadDirectory(bucket, local, path)) + uploads.push(exports.uploadDirectory(bucketName, local, path)) } else { uploads.push( - exports.streamUpload(bucket, path, fs.createReadStream(local)) + exports.streamUpload(bucketName, path, fs.createReadStream(local)) ) } } await Promise.all(uploads) } -exports.downloadTarball = async (url, bucket, path) => { +exports.downloadTarball = async (url, bucketName, path) => { + bucketName = sanitizeBucket(bucketName) + path = sanitizeKey(path) const response = await fetch(url) if (!response.ok) { throw new Error(`unexpected response ${response.statusText}`) @@ -233,7 +258,7 @@ exports.downloadTarball = async (url, bucket, path) => { const tmpPath = join(budibaseTempDir(), path) await streamPipeline(response.body, zlib.Unzip(), tar.extract(tmpPath)) if (!env.isTest()) { - await exports.uploadDirectory(bucket, tmpPath, path) + await exports.uploadDirectory(bucketName, tmpPath, path) } // return the temporary path incase there is a use for it return tmpPath diff --git a/packages/auth/src/redis/index.js b/packages/auth/src/redis/index.js index dc670c07fb..5db80a216b 100644 --- a/packages/auth/src/redis/index.js +++ b/packages/auth/src/redis/index.js @@ -143,9 +143,8 @@ class RedisWrapper { } async clear() { - const db = this._db - let items = await this.scan(db) - await Promise.all(items.map(obj => this.delete(db, obj.key))) + let items = await this.scan() + await Promise.all(items.map(obj => this.delete(obj.key))) } } diff --git a/packages/auth/src/redis/utils.js b/packages/auth/src/redis/utils.js index bd4a762e1d..efdd2aa48d 100644 --- a/packages/auth/src/redis/utils.js +++ b/packages/auth/src/redis/utils.js @@ -9,6 +9,7 @@ const REDIS_PASSWORD = !env.REDIS_PASSWORD ? "budibase" : env.REDIS_PASSWORD exports.Databases = { PW_RESETS: "pwReset", INVITATIONS: "invitation", + DEV_LOCKS: "devLocks", } exports.getRedisOptions = (clustered = false) => { @@ -31,6 +32,9 @@ exports.getRedisOptions = (clustered = false) => { } exports.addDbPrefix = (db, key) => { + if (key.includes(db)) { + return key + } return `${db}${SEPARATOR}${key}` } diff --git a/packages/server/src/utilities/security/permissions.js b/packages/auth/src/security/permissions.js similarity index 100% rename from packages/server/src/utilities/security/permissions.js rename to packages/auth/src/security/permissions.js diff --git a/packages/server/src/utilities/security/roles.js b/packages/auth/src/security/roles.js similarity index 84% rename from packages/server/src/utilities/security/roles.js rename to packages/auth/src/security/roles.js index abfaa5c241..d652c25b00 100644 --- a/packages/server/src/utilities/security/roles.js +++ b/packages/auth/src/security/roles.js @@ -1,7 +1,12 @@ -const CouchDB = require("../../db") +const { getDB } = require("../db") const { cloneDeep } = require("lodash/fp") const { BUILTIN_PERMISSION_IDS, higherPermission } = require("./permissions") -const { generateRoleID, DocumentTypes, SEPARATOR } = require("../../db/utils") +const { + generateRoleID, + getRoleParams, + DocumentTypes, + SEPARATOR, +} = require("../db/utils") const BUILTIN_IDS = { ADMIN: "ADMIN", @@ -11,6 +16,14 @@ const BUILTIN_IDS = { BUILDER: "BUILDER", } +// exclude internal roles like builder +const EXTERNAL_BUILTIN_ROLE_IDS = [ + BUILTIN_IDS.ADMIN, + BUILTIN_IDS.POWER, + BUILTIN_IDS.BASIC, + BUILTIN_IDS.PUBLIC, +] + function Role(id, name) { this._id = id this.name = name @@ -116,7 +129,7 @@ exports.getRole = async (appId, roleId) => { ) } try { - const db = new CouchDB(appId) + const db = getDB(appId) const dbRole = await db.get(exports.getDBRoleID(roleId)) role = Object.assign(role, dbRole) // finalise the ID @@ -192,6 +205,39 @@ exports.getUserPermissions = async (appId, userRoleId) => { } } +/** + * Given an app ID this will retrieve all of the roles that are currently within that app. + * @param {string} appId The ID of the app to retrieve the roles from. + * @return {Promise} An array of the role objects that were found. + */ +exports.getAllRoles = async appId => { + const db = getDB(appId) + const body = await db.allDocs( + getRoleParams(null, { + include_docs: true, + }) + ) + let roles = body.rows.map(row => row.doc) + const builtinRoles = exports.getBuiltinRoles() + + // need to combine builtin with any DB record of them (for sake of permissions) + for (let builtinRoleId of EXTERNAL_BUILTIN_ROLE_IDS) { + const builtinRole = builtinRoles[builtinRoleId] + const dbBuiltin = roles.filter( + dbRole => exports.getExternalRoleID(dbRole._id) === builtinRoleId + )[0] + if (dbBuiltin == null) { + roles.push(builtinRole) + } else { + // remove role and all back after combining with the builtin + roles = roles.filter(role => role._id !== dbBuiltin._id) + dbBuiltin._id = exports.getExternalRoleID(dbBuiltin._id) + roles.push(Object.assign(builtinRole, dbBuiltin)) + } + } + return roles +} + class AccessController { constructor(appId) { this.appId = appId diff --git a/packages/bbui/src/Link/Link.svelte b/packages/bbui/src/Link/Link.svelte index 6447993430..f66554bd75 100644 --- a/packages/bbui/src/Link/Link.svelte +++ b/packages/bbui/src/Link/Link.svelte @@ -11,6 +11,7 @@ { store.actions = { initialise: async pkg => { const { layouts, screens, application, clientLibPath } = pkg - const components = await fetchComponentLibDefinitions(application._id) + const components = await fetchComponentLibDefinitions(application.appId) store.update(state => ({ ...state, libraries: application.componentLibraries, components, name: application.name, description: application.description, - appId: application._id, + appId: application.appId, url: application.url, layouts, screens, diff --git a/packages/builder/src/builderStore/store/hosting.js b/packages/builder/src/builderStore/store/hosting.js index f180d4157a..fb174c2663 100644 --- a/packages/builder/src/builderStore/store/hosting.js +++ b/packages/builder/src/builderStore/store/hosting.js @@ -2,7 +2,6 @@ import { writable } from "svelte/store" import api, { get } from "../api" const INITIAL_HOSTING_UI_STATE = { - hostingInfo: {}, appUrl: "", deployedApps: {}, deployedAppNames: [], @@ -13,28 +12,12 @@ export const getHostingStore = () => { const store = writable({ ...INITIAL_HOSTING_UI_STATE }) store.actions = { fetch: async () => { - const responses = await Promise.all([ - api.get("/api/hosting/"), - api.get("/api/hosting/urls"), - ]) - const [info, urls] = await Promise.all(responses.map(resp => resp.json())) + const response = await api.get("/api/hosting/urls") + const urls = await response.json() store.update(state => { - state.hostingInfo = info state.appUrl = urls.app return state }) - return info - }, - save: async hostingInfo => { - const response = await api.post("/api/hosting", hostingInfo) - const revision = (await response.json()).rev - store.update(state => { - state.hostingInfo = { - ...hostingInfo, - _rev: revision, - } - return state - }) }, fetchDeployedApps: async () => { let deployments = await (await get("/api/hosting/apps")).json() diff --git a/packages/builder/src/components/common/bindings/ServerBindingPanel.svelte b/packages/builder/src/components/common/bindings/ServerBindingPanel.svelte index ca6d211a2a..835069ea9c 100644 --- a/packages/builder/src/components/common/bindings/ServerBindingPanel.svelte +++ b/packages/builder/src/components/common/bindings/ServerBindingPanel.svelte @@ -50,7 +50,7 @@
{#each categories as [categoryName, bindings]} {categoryName} - {#each bindableProperties.filter(binding => + {#each bindings.filter(binding => binding.label.match(searchRgx) ) as binding}
+ import { onMount, onDestroy } from "svelte" + import { Button, Modal, notifications, ModalContent } from "@budibase/bbui" + import { store } from "builderStore" + import api from "builderStore/api" + import analytics from "analytics" + import FeedbackIframe from "components/feedback/FeedbackIframe.svelte" + + const DeploymentStatus = { + SUCCESS: "SUCCESS", + PENDING: "PENDING", + FAILURE: "FAILURE", + } + + const POLL_INTERVAL = 1000 + + let loading = false + let feedbackModal + let deployments = [] + let poll + let publishModal + + $: appId = $store.appId + + async function deployApp() { + try { + notifications.info(`Deployment started. Please wait.`) + const response = await api.post("/api/deploy") + const json = await response.json() + if (response.status !== 200) { + throw new Error() + } + + if (analytics.requestFeedbackOnDeploy()) { + feedbackModal.show() + } + } catch (err) { + analytics.captureException(err) + notifications.error("Deployment unsuccessful. Please try again later.") + } + } + + async function fetchDeployments() { + try { + const response = await api.get(`/api/deployments`) + const json = await response.json() + + if (deployments.length > 0) { + checkIncomingDeploymentStatus(deployments, json) + } + + deployments = json + } catch (err) { + console.error(err) + clearInterval(poll) + notifications.error( + "Error fetching deployment history. Please try again." + ) + } + } + + // Required to check any updated deployment statuses between polls + function checkIncomingDeploymentStatus(current, incoming) { + console.log(current, incoming) + for (let incomingDeployment of incoming) { + if ( + incomingDeployment.status === DeploymentStatus.FAILURE || + incomingDeployment.status === DeploymentStatus.SUCCESS + ) { + const currentDeployment = current.find( + deployment => deployment._id === incomingDeployment._id + ) + + // We have just been notified of an ongoing deployments status change + if ( + !currentDeployment || + currentDeployment.status === DeploymentStatus.PENDING + ) { + if (incomingDeployment.status === DeploymentStatus.FAILURE) { + notifications.error(incomingDeployment.err) + } else { + notifications.send( + "Published to Production.", + "success", + "CheckmarkCircle" + ) + } + } + } + } + } + + onMount(() => { + fetchDeployments() + poll = setInterval(fetchDeployments, POLL_INTERVAL) + }) + + onDestroy(() => clearInterval(poll)) + + + + + + The changes you have made will be published to the production version of + the application. + + diff --git a/packages/builder/src/components/deploy/DeploymentHistory.svelte b/packages/builder/src/components/deploy/DeploymentHistory.svelte index fd098235be..dc90acf435 100644 --- a/packages/builder/src/components/deploy/DeploymentHistory.svelte +++ b/packages/builder/src/components/deploy/DeploymentHistory.svelte @@ -36,8 +36,7 @@ let errorReason let poll let deployments = [] - let urlComponent = - $hostingStore.hostingInfo.type === "self" ? $store.url : `/${appId}` + let urlComponent = $store.url || `/${appId}` let deploymentUrl = `${$hostingStore.appUrl}${urlComponent}` const formatDate = (date, format) => diff --git a/packages/builder/src/components/deploy/RevertModal.svelte b/packages/builder/src/components/deploy/RevertModal.svelte new file mode 100644 index 0000000000..d10aa95acf --- /dev/null +++ b/packages/builder/src/components/deploy/RevertModal.svelte @@ -0,0 +1,50 @@ + + + + + + The changes you have made will be deleted and the application reverted + back to its production state. + + diff --git a/packages/builder/src/components/settings/SettingsModal.svelte b/packages/builder/src/components/settings/SettingsModal.svelte index 5563ad9fbd..f881720305 100644 --- a/packages/builder/src/components/settings/SettingsModal.svelte +++ b/packages/builder/src/components/settings/SettingsModal.svelte @@ -1,5 +1,5 @@ @@ -13,9 +13,9 @@ - + diff --git a/packages/builder/src/components/settings/tabs/General.svelte b/packages/builder/src/components/settings/tabs/General.svelte index d5829f9e72..4a8d24ed58 100644 --- a/packages/builder/src/components/settings/tabs/General.svelte +++ b/packages/builder/src/components/settings/tabs/General.svelte @@ -49,27 +49,22 @@ onMount(async () => { const nameError = "Your application must have a name.", urlError = "Your application must have a URL." - let hostingInfo = await hostingStore.actions.fetch() - if (hostingInfo.type === "self") { - await hostingStore.actions.fetchDeployedApps() - const existingAppNames = get(hostingStore).deployedAppNames - const existingAppUrls = get(hostingStore).deployedAppUrls - const nameIdx = existingAppNames.indexOf(get(store).name) - const urlIdx = existingAppUrls.indexOf(get(store).url) - if (nameIdx !== -1) { - existingAppNames.splice(nameIdx, 1) - } - if (urlIdx !== -1) { - existingAppUrls.splice(urlIdx, 1) - } - nameValidation = { - name: string().required(nameError).notOneOf(existingAppNames), - } - urlValidation = { - url: string().required(urlError).notOneOf(existingAppUrls), - } - } else { - nameValidation = { name: string().required(nameError) } + await hostingStore.actions.fetchDeployedApps() + const existingAppNames = get(hostingStore).deployedAppNames + const existingAppUrls = get(hostingStore).deployedAppUrls + const nameIdx = existingAppNames.indexOf(get(store).name) + const urlIdx = existingAppUrls.indexOf(get(store).url) + if (nameIdx !== -1) { + existingAppNames.splice(nameIdx, 1) + } + if (urlIdx !== -1) { + existingAppUrls.splice(urlIdx, 1) + } + nameValidation = { + name: string().required(nameError).notOneOf(existingAppNames), + } + urlValidation = { + url: string().required(urlError).notOneOf(existingAppUrls), } }) @@ -81,14 +76,12 @@ error={nameError} label="App Name" /> - {#if $hostingStore.hostingInfo.type === "self"} - updateApplication({ url: e.detail })} - value={$store.url} - error={urlError} - label="App URL" - /> - {/if} + updateApplication({ url: e.detail })} + value={$store.url} + error={urlError} + label="App URL" + />