diff --git a/.prettierrc.json b/.prettierrc.json index 39654fd9f9..dae5906124 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -4,7 +4,7 @@ "singleQuote": false, "trailingComma": "es5", "arrowParens": "avoid", - "jsxBracketSameLine": false, + "bracketSameLine": false, "plugins": ["prettier-plugin-svelte"], "svelteSortOrder": "options-scripts-markup-styles" } diff --git a/charts/budibase/templates/app-service-deployment.yaml b/charts/budibase/templates/app-service-deployment.yaml index fd46e77647..422097e699 100644 --- a/charts/budibase/templates/app-service-deployment.yaml +++ b/charts/budibase/templates/app-service-deployment.yaml @@ -130,6 +130,22 @@ spec: - name: BB_ADMIN_USER_PASSWORD value: { { .Values.globals.bbAdminUserPassword | quote } } {{ end }} + {{ if .Values.services.apps.nodeDebug }} + - name: NODE_DEBUG + value: {{ .Values.services.apps.nodeDebug | quote }} + {{ end }} + {{ if .Values.globals.elasticApmEnabled }} + - name: ELASTIC_APM_ENABLED + value: {{ .Values.globals.elasticApmEnabled | quote }} + {{ end }} + {{ if .Values.globals.elasticApmSecretToken }} + - name: ELASTIC_APM_SECRET_TOKEN + value: {{ .Values.globals.elasticApmSecretToken | quote }} + {{ end }} + {{ if .Values.globals.elasticApmServerUrl }} + - name: ELASTIC_APM_SERVER_URL + value: {{ .Values.globals.elasticApmServerUrl | quote }} + {{ end }} image: budibase/apps:{{ .Values.globals.appVersion }} imagePullPolicy: Always @@ -142,7 +158,10 @@ spec: name: bbapps ports: - containerPort: {{ .Values.services.apps.port }} - resources: {} + {{ with .Values.services.apps.resources }} + resources: + {{- toYaml . | nindent 10 }} + {{ end }} {{- with .Values.affinity }} affinity: {{- toYaml . | nindent 8 }} diff --git a/charts/budibase/templates/couchdb-backup.yaml b/charts/budibase/templates/couchdb-backup.yaml index ae062475ce..68e5eab617 100644 --- a/charts/budibase/templates/couchdb-backup.yaml +++ b/charts/budibase/templates/couchdb-backup.yaml @@ -38,7 +38,10 @@ spec: image: redgeoff/replicate-couchdb-cluster imagePullPolicy: Always name: couchdb-backup - resources: {} + {{ with .Values.services.couchdb.backup.resources }} + resources: + {{- toYaml . | nindent 10 }} + {{ end }} {{- with .Values.affinity }} affinity: {{- toYaml . | nindent 8 }} diff --git a/charts/budibase/templates/minio-service-deployment.yaml b/charts/budibase/templates/minio-service-deployment.yaml index 103f9e3ed2..144dbe539a 100644 --- a/charts/budibase/templates/minio-service-deployment.yaml +++ b/charts/budibase/templates/minio-service-deployment.yaml @@ -56,7 +56,10 @@ spec: name: minio-service ports: - containerPort: {{ .Values.services.objectStore.port }} - resources: {} + {{ with .Values.services.objectStore.resources }} + resources: + {{- toYaml . | nindent 10 }} + {{ end }} volumeMounts: - mountPath: /data name: minio-data diff --git a/charts/budibase/templates/proxy-service-deployment.yaml b/charts/budibase/templates/proxy-service-deployment.yaml index 505a46f1e8..5588022032 100644 --- a/charts/budibase/templates/proxy-service-deployment.yaml +++ b/charts/budibase/templates/proxy-service-deployment.yaml @@ -30,7 +30,10 @@ spec: name: proxy-service ports: - containerPort: {{ .Values.services.proxy.port }} - resources: {} + {{ with .Values.services.proxy.resources }} + resources: + {{- toYaml . | nindent 10 }} + {{ end }} volumeMounts: {{- with .Values.affinity }} affinity: diff --git a/charts/budibase/templates/redis-service-deployment.yaml b/charts/budibase/templates/redis-service-deployment.yaml index 6e09346cad..d94e4d70f8 100644 --- a/charts/budibase/templates/redis-service-deployment.yaml +++ b/charts/budibase/templates/redis-service-deployment.yaml @@ -35,7 +35,10 @@ spec: name: redis-service ports: - containerPort: {{ .Values.services.redis.port }} - resources: {} + {{ with .Values.services.redis.resources }} + resources: + {{- toYaml . | nindent 10 }} + {{ end }} volumeMounts: - mountPath: /data name: redis-data diff --git a/charts/budibase/templates/worker-service-deployment.yaml b/charts/budibase/templates/worker-service-deployment.yaml index 918dab427b..902e9ac03d 100644 --- a/charts/budibase/templates/worker-service-deployment.yaml +++ b/charts/budibase/templates/worker-service-deployment.yaml @@ -27,6 +27,8 @@ spec: spec: containers: - env: + - name: BUDIBASE_ENVIRONMENT + value: {{ .Values.globals.budibaseEnv }} - name: DEPLOYMENT_ENVIRONMENT value: "kubernetes" - name: CLUSTER_PORT @@ -125,6 +127,19 @@ spec: value: {{ .Values.globals.google.secret | quote }} - name: TENANT_FEATURE_FLAGS value: {{ .Values.globals.tenantFeatureFlags | quote }} + {{ if .Values.globals.elasticApmEnabled }} + - name: ELASTIC_APM_ENABLED + value: {{ .Values.globals.elasticApmEnabled | quote }} + {{ end }} + {{ if .Values.globals.elasticApmSecretToken }} + - name: ELASTIC_APM_SECRET_TOKEN + value: {{ .Values.globals.elasticApmSecretToken | quote }} + {{ end }} + {{ if .Values.globals.elasticApmServerUrl }} + - name: ELASTIC_APM_SERVER_URL + value: {{ .Values.globals.elasticApmServerUrl | quote }} + {{ end }} + image: budibase/worker:{{ .Values.globals.appVersion }} imagePullPolicy: Always livenessProbe: @@ -136,7 +151,10 @@ spec: name: bbworker ports: - containerPort: {{ .Values.services.worker.port }} - resources: {} + {{ with .Values.services.worker.resources }} + resources: + {{- toYaml . | nindent 10 }} + {{ end }} {{- with .Values.affinity }} affinity: {{- toYaml . | nindent 8 }} diff --git a/charts/budibase/values.yaml b/charts/budibase/values.yaml index 404e92c70f..a15504d58c 100644 --- a/charts/budibase/values.yaml +++ b/charts/budibase/values.yaml @@ -60,19 +60,6 @@ ingress: port: number: 10000 -resources: - {} - # We usually recommend not to specify default resources and to leave this as a conscious - # choice for the user. This also increases chances charts run on environments with little - # resources, such as Minikube. If you do want to specify resources, uncomment the following - # lines, adjust them as necessary, and remove the curly braces after 'resources:'. - # limits: - # cpu: 100m - # memory: 128Mi - # requests: - # cpu: 100m - # memory: 128Mi - autoscaling: enabled: false minReplicas: 1 @@ -114,6 +101,10 @@ globals: smtp: enabled: false +# elasticApmEnabled: +# elasticApmSecretToken: +# elasticApmServerUrl: + services: budibaseVersion: latest dns: cluster.local @@ -121,15 +112,19 @@ services: proxy: port: 10000 replicaCount: 1 + resources: {} apps: port: 4002 replicaCount: 1 logLevel: info + resources: {} +# nodeDebug: "" # set the value of NODE_DEBUG worker: port: 4003 replicaCount: 1 + resources: {} couchdb: enabled: true @@ -143,6 +138,7 @@ services: target: "" # backup interval in seconds interval: "" + resources: {} redis: enabled: true # disable if using external redis @@ -156,6 +152,7 @@ services: ## If undefined (the default) or set to null, no storageClassName spec is ## set, choosing the default provisioner. storageClass: "" + resources: {} objectStore: minio: true @@ -172,6 +169,7 @@ services: ## If undefined (the default) or set to null, no storageClassName spec is ## set, choosing the default provisioner. storageClass: "" + resources: {} # Override values in couchDB subchart couchdb: diff --git a/hosting/docker-compose.yaml b/hosting/docker-compose.yaml index 7d3e6960dc..c55ca34547 100644 --- a/hosting/docker-compose.yaml +++ b/hosting/docker-compose.yaml @@ -78,6 +78,7 @@ services: image: budibase/proxy environment: - PROXY_RATE_LIMIT_WEBHOOKS_PER_SECOND=10 + - PROXY_RATE_LIMIT_API_PER_SECOND=20 depends_on: - minio-service - worker-service diff --git a/hosting/nginx.dev.conf.hbs b/hosting/nginx.dev.conf.hbs index e08516c9d3..20c4d3d182 100644 --- a/hosting/nginx.dev.conf.hbs +++ b/hosting/nginx.dev.conf.hbs @@ -15,7 +15,10 @@ http { log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; + '"$http_user_agent" "$http_x_forwarded_for" ' + 'response_time=$upstream_response_time proxy_host=$proxy_host upstream_addr=$upstream_addr'; + + access_log /var/log/nginx/access.log main; map $http_upgrade $connection_upgrade { default "upgrade"; diff --git a/hosting/nginx.prod.conf.hbs b/hosting/nginx.prod.conf.hbs index eaff214187..0ff986d0a7 100644 --- a/hosting/nginx.prod.conf.hbs +++ b/hosting/nginx.prod.conf.hbs @@ -11,7 +11,7 @@ events { http { # rate limiting limit_req_status 429; - limit_req_zone $binary_remote_addr zone=ratelimit:10m rate=20r/s; + limit_req_zone $binary_remote_addr zone=ratelimit:10m rate=${PROXY_RATE_LIMIT_API_PER_SECOND}r/s; limit_req_zone $binary_remote_addr zone=webhooks:10m rate=${PROXY_RATE_LIMIT_WEBHOOKS_PER_SECOND}r/s; include /etc/nginx/mime.types; @@ -33,7 +33,10 @@ http { log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; + '"$http_user_agent" "$http_x_forwarded_for" ' + 'response_time=$upstream_response_time proxy_host=$proxy_host upstream_addr=$upstream_addr'; + + access_log /var/log/nginx/access.log main; map $http_upgrade $connection_upgrade { default "upgrade"; diff --git a/hosting/proxy/Dockerfile b/hosting/proxy/Dockerfile index d9b33e3e9a..298762aaf1 100644 --- a/hosting/proxy/Dockerfile +++ b/hosting/proxy/Dockerfile @@ -10,4 +10,5 @@ COPY .generated-nginx.prod.conf /etc/nginx/templates/nginx.conf.template COPY error.html /usr/share/nginx/html/error.html # Default environment -ENV PROXY_RATE_LIMIT_WEBHOOKS_PER_SECOND=10 \ No newline at end of file +ENV PROXY_RATE_LIMIT_WEBHOOKS_PER_SECOND=10 +ENV PROXY_RATE_LIMIT_API_PER_SECOND=20 \ No newline at end of file diff --git a/lerna.json b/lerna.json index 701a020419..a2dcf1a4ad 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "1.2.44-alpha.6", + "version": "1.3.4-alpha.2", "npmClient": "yarn", "packages": [ "packages/*" diff --git a/packages/backend-core/package.json b/packages/backend-core/package.json index 35fe29567c..d6d8d83530 100644 --- a/packages/backend-core/package.json +++ b/packages/backend-core/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/backend-core", - "version": "1.2.44-alpha.6", + "version": "1.3.4-alpha.2", "description": "Budibase backend core libraries used in server and worker", "main": "dist/src/index.js", "types": "dist/src/index.d.ts", @@ -20,7 +20,7 @@ "test:watch": "jest --watchAll" }, "dependencies": { - "@budibase/types": "1.2.44-alpha.6", + "@budibase/types": "1.3.4-alpha.2", "@techpass/passport-openidconnect": "0.3.2", "aws-sdk": "2.1030.0", "bcrypt": "5.0.1", diff --git a/packages/backend-core/src/auth.js b/packages/backend-core/src/auth.ts similarity index 73% rename from packages/backend-core/src/auth.js rename to packages/backend-core/src/auth.ts index d39b8426fb..23873b84e7 100644 --- a/packages/backend-core/src/auth.js +++ b/packages/backend-core/src/auth.ts @@ -1,11 +1,11 @@ const passport = require("koa-passport") const LocalStrategy = require("passport-local").Strategy const JwtStrategy = require("passport-jwt").Strategy -const { getGlobalDB } = require("./tenancy") +import { getGlobalDB } from "./tenancy" const refresh = require("passport-oauth2-refresh") -const { Configs } = require("./constants") -const { getScopedConfig } = require("./db/utils") -const { +import { Configs } from "./constants" +import { getScopedConfig } from "./db/utils" +import { jwt, local, authenticated, @@ -13,7 +13,6 @@ const { oidc, auditLog, tenancy, - appTenancy, authError, ssoCallbackUrl, csrf, @@ -22,32 +21,36 @@ const { builderOnly, builderOrAdmin, joiValidator, -} = require("./middleware") - -const { invalidateUser } = require("./cache/user") +} from "./middleware" +import { invalidateUser } from "./cache/user" +import { User } from "@budibase/types" // Strategies passport.use(new LocalStrategy(local.options, local.authenticate)) passport.use(new JwtStrategy(jwt.options, jwt.authenticate)) -passport.serializeUser((user, done) => done(null, user)) +passport.serializeUser((user: User, done: any) => done(null, user)) -passport.deserializeUser(async (user, done) => { +passport.deserializeUser(async (user: User, done: any) => { const db = getGlobalDB() try { - const user = await db.get(user._id) - return done(null, user) + const dbUser = await db.get(user._id) + return done(null, dbUser) } catch (err) { console.error(`User not found`, err) return done(null, false, { message: "User not found" }) } }) -async function refreshOIDCAccessToken(db, chosenConfig, refreshToken) { +async function refreshOIDCAccessToken( + db: any, + chosenConfig: any, + refreshToken: string +) { const callbackUrl = await oidc.getCallbackUrl(db, chosenConfig) - let enrichedConfig - let strategy + let enrichedConfig: any + let strategy: any try { enrichedConfig = await oidc.fetchStrategyConfig(chosenConfig, callbackUrl) @@ -70,22 +73,28 @@ async function refreshOIDCAccessToken(db, chosenConfig, refreshToken) { refresh.requestNewAccessToken( Configs.OIDC, refreshToken, - (err, accessToken, refreshToken, params) => { + (err: any, accessToken: string, refreshToken: any, params: any) => { resolve({ err, accessToken, refreshToken, params }) } ) }) } -async function refreshGoogleAccessToken(db, config, refreshToken) { +async function refreshGoogleAccessToken( + db: any, + config: any, + refreshToken: any +) { let callbackUrl = await google.getCallbackUrl(db, config) let strategy try { strategy = await google.strategyFactory(config, callbackUrl) - } catch (err) { + } catch (err: any) { console.error(err) - throw new Error("Error constructing OIDC refresh strategy", err) + throw new Error( + `Error constructing OIDC refresh strategy: message=${err.message}` + ) } refresh.use(strategy) @@ -94,14 +103,18 @@ async function refreshGoogleAccessToken(db, config, refreshToken) { refresh.requestNewAccessToken( Configs.GOOGLE, refreshToken, - (err, accessToken, refreshToken, params) => { + (err: any, accessToken: string, refreshToken: string, params: any) => { resolve({ err, accessToken, refreshToken, params }) } ) }) } -async function refreshOAuthToken(refreshToken, configType, configId) { +async function refreshOAuthToken( + refreshToken: string, + configType: string, + configId: string +) { const db = getGlobalDB() const config = await getScopedConfig(db, { @@ -113,7 +126,7 @@ async function refreshOAuthToken(refreshToken, configType, configId) { let refreshResponse if (configType === Configs.OIDC) { // configId - retrieved from cookie. - chosenConfig = config.configs.filter(c => c.uuid === configId)[0] + chosenConfig = config.configs.filter((c: any) => c.uuid === configId)[0] if (!chosenConfig) { throw new Error("Invalid OIDC configuration") } @@ -134,7 +147,7 @@ async function refreshOAuthToken(refreshToken, configType, configId) { return refreshResponse } -async function updateUserOAuth(userId, oAuthConfig) { +async function updateUserOAuth(userId: string, oAuthConfig: any) { const details = { accessToken: oAuthConfig.accessToken, refreshToken: oAuthConfig.refreshToken, @@ -162,14 +175,13 @@ async function updateUserOAuth(userId, oAuthConfig) { } } -module.exports = { +export = { buildAuthMiddleware: authenticated, passport, google, oidc, jwt: require("jsonwebtoken"), buildTenancyMiddleware: tenancy, - buildAppTenancyMiddleware: appTenancy, auditLog, authError, buildCsrfMiddleware: csrf, diff --git a/packages/backend-core/src/db/constants.ts b/packages/backend-core/src/db/constants.ts index 460476da24..fd464ba5fb 100644 --- a/packages/backend-core/src/db/constants.ts +++ b/packages/backend-core/src/db/constants.ts @@ -18,6 +18,7 @@ export enum ViewName { LINK = "by_link", ROUTING = "screen_routes", AUTOMATION_LOGS = "automation_logs", + ACCOUNT_BY_EMAIL = "account_by_email", } export const DeprecatedViews = { @@ -41,6 +42,7 @@ export enum DocumentType { MIGRATIONS = "migrations", DEV_INFO = "devinfo", AUTOMATION_LOG = "log_au", + ACCOUNT_METADATA = "acc_metadata", } export const StaticDatabases = { diff --git a/packages/backend-core/src/db/views.js b/packages/backend-core/src/db/views.js index 3a45611a8f..b2562bdc71 100644 --- a/packages/backend-core/src/db/views.js +++ b/packages/backend-core/src/db/views.js @@ -5,6 +5,8 @@ const { SEPARATOR, } = require("./utils") const { getGlobalDB } = require("../tenancy") +const { StaticDatabases } = require("./constants") +const { doWithDB } = require("./") const DESIGN_DB = "_design/database" @@ -56,6 +58,31 @@ exports.createNewUserEmailView = async () => { await db.put(designDoc) } +exports.createAccountEmailView = async () => { + await doWithDB(StaticDatabases.PLATFORM_INFO.name, async db => { + let designDoc + try { + designDoc = await db.get(DESIGN_DB) + } catch (err) { + // no design doc, make one + designDoc = DesignDoc() + } + const view = { + // if using variables in a map function need to inject them before use + map: `function(doc) { + if (doc._id.startsWith("${DocumentType.ACCOUNT_METADATA}${SEPARATOR}")) { + emit(doc.email.toLowerCase(), doc._id) + } + }`, + } + designDoc.views = { + ...designDoc.views, + [ViewName.ACCOUNT_BY_EMAIL]: view, + } + await db.put(designDoc) + }) +} + exports.createUserAppView = async () => { const db = getGlobalDB() let designDoc @@ -128,6 +155,39 @@ exports.createUserBuildersView = async () => { await db.put(designDoc) } +exports.queryView = async (viewName, params, db, CreateFuncByName) => { + try { + let response = (await db.query(`database/${viewName}`, params)).rows + response = response.map(resp => + params.include_docs ? resp.doc : resp.value + ) + if (params.arrayResponse) { + return response + } else { + return response.length <= 1 ? response[0] : response + } + } catch (err) { + if (err != null && err.name === "not_found") { + const createFunc = CreateFuncByName[viewName] + await removeDeprecated(db, viewName) + await createFunc() + return exports.queryView(viewName, params, db, CreateFuncByName) + } else { + throw err + } + } +} + +exports.queryPlatformView = async (viewName, params) => { + const CreateFuncByName = { + [ViewName.ACCOUNT_BY_EMAIL]: exports.createAccountEmailView, + } + + return doWithDB(StaticDatabases.PLATFORM_INFO.name, async db => { + return exports.queryView(viewName, params, db, CreateFuncByName) + }) +} + exports.queryGlobalView = async (viewName, params, db = null) => { const CreateFuncByName = { [ViewName.USER_BY_EMAIL]: exports.createNewUserEmailView, @@ -139,20 +199,5 @@ exports.queryGlobalView = async (viewName, params, db = null) => { if (!db) { db = getGlobalDB() } - try { - let response = (await db.query(`database/${viewName}`, params)).rows - response = response.map(resp => - params.include_docs ? resp.doc : resp.value - ) - return response.length <= 1 ? response[0] : response - } catch (err) { - if (err != null && err.name === "not_found") { - const createFunc = CreateFuncByName[viewName] - await removeDeprecated(db, viewName) - await createFunc() - return exports.queryGlobalView(viewName, params) - } else { - throw err - } - } + return exports.queryView(viewName, params, db, CreateFuncByName) } diff --git a/packages/backend-core/src/events/index.ts b/packages/backend-core/src/events/index.ts index 814399655d..f94c8b0267 100644 --- a/packages/backend-core/src/events/index.ts +++ b/packages/backend-core/src/events/index.ts @@ -8,4 +8,5 @@ import { processors } from "./processors" export const shutdown = () => { processors.shutdown() + console.log("Events shutdown") } diff --git a/packages/backend-core/src/index.ts b/packages/backend-core/src/index.ts index 6d2e8dcd10..74e79e7b95 100644 --- a/packages/backend-core/src/index.ts +++ b/packages/backend-core/src/index.ts @@ -17,6 +17,7 @@ import constants from "./constants" import * as dbConstants from "./db/constants" import logging from "./logging" import pino from "./pino" +import * as middleware from "./middleware" // mimic the outer package exports import * as db from "./pkg/db" @@ -57,6 +58,7 @@ const core = { roles, ...pino, ...errorClasses, + middleware, } export = core diff --git a/packages/backend-core/src/middleware/authenticated.ts b/packages/backend-core/src/middleware/authenticated.ts index b51ead46b9..062070785d 100644 --- a/packages/backend-core/src/middleware/authenticated.ts +++ b/packages/backend-core/src/middleware/authenticated.ts @@ -65,7 +65,7 @@ async function checkApiKey(apiKey: string, populateUser?: Function) { * The tenancy modules should not be used here and it should be assumed that the tenancy context * has not yet been populated. */ -module.exports = ( +export = ( noAuthPatterns = [], opts: { publicAllowed: boolean; populateUser?: Function } = { publicAllowed: false, diff --git a/packages/backend-core/src/middleware/index.js b/packages/backend-core/src/middleware/index.ts similarity index 96% rename from packages/backend-core/src/middleware/index.js rename to packages/backend-core/src/middleware/index.ts index 7e7b8a2931..998c231b3d 100644 --- a/packages/backend-core/src/middleware/index.js +++ b/packages/backend-core/src/middleware/index.ts @@ -13,7 +13,8 @@ const adminOnly = require("./adminOnly") const builderOrAdmin = require("./builderOrAdmin") const builderOnly = require("./builderOnly") const joiValidator = require("./joi-validator") -module.exports = { + +const pkg = { google, oidc, jwt, @@ -33,3 +34,5 @@ module.exports = { builderOrAdmin, joiValidator, } + +export = pkg diff --git a/packages/backend-core/src/middleware/joi-validator.js b/packages/backend-core/src/middleware/joi-validator.js index 748ccebd89..6812dbdd54 100644 --- a/packages/backend-core/src/middleware/joi-validator.js +++ b/packages/backend-core/src/middleware/joi-validator.js @@ -13,10 +13,13 @@ function validate(schema, property) { params = ctx.request[property] } - schema = schema.append({ - createdAt: Joi.any().optional(), - updatedAt: Joi.any().optional(), - }) + // not all schemas have the append property e.g. array schemas + if (schema.append) { + schema = schema.append({ + createdAt: Joi.any().optional(), + updatedAt: Joi.any().optional(), + }) + } const { error } = schema.validate(params) if (error) { diff --git a/packages/backend-core/src/objectStore/index.ts b/packages/backend-core/src/objectStore/index.ts index 503ab9bca0..1b880ef7b2 100644 --- a/packages/backend-core/src/objectStore/index.ts +++ b/packages/backend-core/src/objectStore/index.ts @@ -66,15 +66,13 @@ const PUBLIC_BUCKETS = [ObjectStoreBuckets.APPS, ObjectStoreBuckets.GLOBAL] * @constructor */ export const ObjectStore = (bucket: any) => { - AWS.config.update({ - accessKeyId: env.MINIO_ACCESS_KEY, - secretAccessKey: env.MINIO_SECRET_KEY, - region: env.AWS_REGION, - }) const config: any = { s3ForcePathStyle: true, signatureVersion: "v4", apiVersion: "2006-03-01", + accessKeyId: env.MINIO_ACCESS_KEY, + secretAccessKey: env.MINIO_SECRET_KEY, + region: env.AWS_REGION, } if (bucket) { config.params = { diff --git a/packages/backend-core/src/security/sessions.ts b/packages/backend-core/src/security/sessions.ts index 284adbcd1f..f621b99dc2 100644 --- a/packages/backend-core/src/security/sessions.ts +++ b/packages/backend-core/src/security/sessions.ts @@ -3,17 +3,27 @@ const { v4: uuidv4 } = require("uuid") const { logWarn } = require("../logging") const env = require("../environment") -interface Session { - key: string - userId: string +interface CreateSession { sessionId: string - lastAccessedAt: string - createdAt: string + tenantId: string csrfToken?: string - value: string } -type SessionKey = { key: string }[] +interface Session extends CreateSession { + userId: string + lastAccessedAt: string + createdAt: string + // make optional attributes required + csrfToken: string +} + +interface SessionKey { + key: string +} + +interface ScannedSession { + value: Session +} // a week in seconds const EXPIRY_SECONDS = 86400 * 7 @@ -22,14 +32,14 @@ function makeSessionID(userId: string, sessionId: string) { return `${userId}/${sessionId}` } -export async function getSessionsForUser(userId: string) { +export async function getSessionsForUser(userId: string): Promise { if (!userId) { console.trace("Cannot get sessions for undefined userId") return [] } const client = await redis.getSessionClient() - const sessions = await client.scan(userId) - return sessions.map((session: Session) => session.value) + const sessions: ScannedSession[] = await client.scan(userId) + return sessions.map(session => session.value) } export async function invalidateSessions( @@ -39,33 +49,32 @@ export async function invalidateSessions( try { const reason = opts?.reason || "unknown" let sessionIds: string[] = opts.sessionIds || [] - let sessions: SessionKey + let sessionKeys: SessionKey[] // If no sessionIds, get all the sessions for the user if (sessionIds.length === 0) { - sessions = await getSessionsForUser(userId) - sessions.forEach( - (session: any) => - (session.key = makeSessionID(session.userId, session.sessionId)) - ) + const sessions = await getSessionsForUser(userId) + sessionKeys = sessions.map(session => ({ + key: makeSessionID(session.userId, session.sessionId), + })) } else { // use the passed array of sessionIds sessionIds = Array.isArray(sessionIds) ? sessionIds : [sessionIds] - sessions = sessionIds.map((sessionId: string) => ({ + sessionKeys = sessionIds.map(sessionId => ({ key: makeSessionID(userId, sessionId), })) } - if (sessions && sessions.length > 0) { + if (sessionKeys && sessionKeys.length > 0) { const client = await redis.getSessionClient() const promises = [] - for (let session of sessions) { - promises.push(client.delete(session.key)) + for (let sessionKey of sessionKeys) { + promises.push(client.delete(sessionKey.key)) } if (!env.isTest()) { logWarn( - `Invalidating sessions for ${userId} (reason: ${reason}) - ${sessions - .map(session => session.key) + `Invalidating sessions for ${userId} (reason: ${reason}) - ${sessionKeys + .map(sessionKey => sessionKey.key) .join(", ")}` ) } @@ -76,22 +85,26 @@ export async function invalidateSessions( } } -export async function createASession(userId: string, session: Session) { +export async function createASession( + userId: string, + createSession: CreateSession +) { // invalidate all other sessions await invalidateSessions(userId, { reason: "creation" }) const client = await redis.getSessionClient() - const sessionId = session.sessionId - if (!session.csrfToken) { - session.csrfToken = uuidv4() - } - session = { - ...session, + const sessionId = createSession.sessionId + const csrfToken = createSession.csrfToken ? createSession.csrfToken : uuidv4() + const key = makeSessionID(userId, sessionId) + + const session: Session = { + ...createSession, + csrfToken, createdAt: new Date().toISOString(), lastAccessedAt: new Date().toISOString(), userId, } - await client.store(makeSessionID(userId, sessionId), session, EXPIRY_SECONDS) + await client.store(key, session, EXPIRY_SECONDS) } export async function updateSessionTTL(session: Session) { @@ -106,7 +119,10 @@ export async function endSession(userId: string, sessionId: string) { await client.delete(makeSessionID(userId, sessionId)) } -export async function getSession(userId: string, sessionId: string) { +export async function getSession( + userId: string, + sessionId: string +): Promise { if (!userId || !sessionId) { throw new Error(`Invalid session details - ${userId} - ${sessionId}`) } diff --git a/packages/backend-core/src/users.js b/packages/backend-core/src/users.js index de5ce238c1..81bf28bb46 100644 --- a/packages/backend-core/src/users.js +++ b/packages/backend-core/src/users.js @@ -11,7 +11,6 @@ const { UNICODE_MAX } = require("./db/constants") * Given an email address this will use a view to search through * all the users to find one with this email address. * @param {string} email the email to lookup the user by. - * @return {Promise} */ exports.getGlobalUserByEmail = async email => { if (email == null) { diff --git a/packages/backend-core/tests/utilities/mocks/accounts.ts b/packages/backend-core/tests/utilities/mocks/accounts.ts new file mode 100644 index 0000000000..79436443db --- /dev/null +++ b/packages/backend-core/tests/utilities/mocks/accounts.ts @@ -0,0 +1,7 @@ +export const getAccount = jest.fn() +export const getAccountByTenantId = jest.fn() + +jest.mock("../../../src/cloud/accounts", () => ({ + getAccount, + getAccountByTenantId, +})) diff --git a/packages/backend-core/tests/utilities/mocks/date.js b/packages/backend-core/tests/utilities/mocks/date.js deleted file mode 100644 index 19248c6f11..0000000000 --- a/packages/backend-core/tests/utilities/mocks/date.js +++ /dev/null @@ -1,2 +0,0 @@ -exports.MOCK_DATE = new Date("2020-01-01T00:00:00.000Z") -exports.MOCK_DATE_TIMESTAMP = 1577836800000 diff --git a/packages/backend-core/tests/utilities/mocks/date.ts b/packages/backend-core/tests/utilities/mocks/date.ts new file mode 100644 index 0000000000..f580b68349 --- /dev/null +++ b/packages/backend-core/tests/utilities/mocks/date.ts @@ -0,0 +1,2 @@ +export const MOCK_DATE = new Date("2020-01-01T00:00:00.000Z") +export const MOCK_DATE_TIMESTAMP = 1577836800000 diff --git a/packages/backend-core/tests/utilities/mocks/events.js b/packages/backend-core/tests/utilities/mocks/events.ts similarity index 100% rename from packages/backend-core/tests/utilities/mocks/events.js rename to packages/backend-core/tests/utilities/mocks/events.ts diff --git a/packages/backend-core/tests/utilities/mocks/index.js b/packages/backend-core/tests/utilities/mocks/index.js deleted file mode 100644 index 6aa1c4a54f..0000000000 --- a/packages/backend-core/tests/utilities/mocks/index.js +++ /dev/null @@ -1,9 +0,0 @@ -const posthog = require("./posthog") -const events = require("./events") -const date = require("./date") - -module.exports = { - posthog, - date, - events, -} diff --git a/packages/backend-core/tests/utilities/mocks/index.ts b/packages/backend-core/tests/utilities/mocks/index.ts new file mode 100644 index 0000000000..7031b225ec --- /dev/null +++ b/packages/backend-core/tests/utilities/mocks/index.ts @@ -0,0 +1,4 @@ +import "./posthog" +import "./events" +export * as accounts from "./accounts" +export * as date from "./date" diff --git a/packages/bbui/package.json b/packages/bbui/package.json index 16a22ccb21..a563b54328 100644 --- a/packages/bbui/package.json +++ b/packages/bbui/package.json @@ -1,7 +1,7 @@ { "name": "@budibase/bbui", "description": "A UI solution used in the different Budibase projects.", - "version": "1.2.44-alpha.6", + "version": "1.3.4-alpha.2", "license": "MPL-2.0", "svelte": "src/index.js", "module": "dist/bbui.es.js", @@ -38,7 +38,7 @@ ], "dependencies": { "@adobe/spectrum-css-workflow-icons": "^1.2.1", - "@budibase/string-templates": "1.2.44-alpha.6", + "@budibase/string-templates": "1.3.4-alpha.2", "@spectrum-css/actionbutton": "^1.0.1", "@spectrum-css/actiongroup": "^1.0.1", "@spectrum-css/avatar": "^3.0.2", diff --git a/packages/bbui/src/Actions/position_dropdown.js b/packages/bbui/src/Actions/position_dropdown.js index a25cc1bbd5..7570a39c8c 100644 --- a/packages/bbui/src/Actions/position_dropdown.js +++ b/packages/bbui/src/Actions/position_dropdown.js @@ -1,4 +1,4 @@ -export default function positionDropdown(element, { anchor, align }) { +export default function positionDropdown(element, { anchor, align, maxWidth }) { let positionSide = "top" let maxHeight = 0 let dimensions = getDimensions(anchor) @@ -34,13 +34,24 @@ export default function positionDropdown(element, { anchor, align }) { } function calcLeftPosition() { - return align === "right" - ? dimensions.left + dimensions.width - dimensions.containerWidth - : dimensions.left + let left + + if (align == "right") { + left = dimensions.left + dimensions.width - dimensions.containerWidth + } else if (align == "right-side") { + left = dimensions.left + dimensions.width + } else { + left = dimensions.left + } + + return left } element.style.position = "absolute" element.style.zIndex = "9999" + if (maxWidth) { + element.style.maxWidth = `${maxWidth}px` + } element.style.minWidth = `${dimensions.width}px` element.style.maxHeight = `${maxHeight.toFixed(0)}px` element.style.transformOrigin = `center ${positionSide}` @@ -54,10 +65,8 @@ export default function positionDropdown(element, { anchor, align }) { element.style.left = `${calcLeftPosition(dimensions).toFixed(0)}px` }) }) - resizeObserver.observe(anchor) resizeObserver.observe(element) - return { destroy() { resizeObserver.disconnect() diff --git a/packages/bbui/src/Form/Core/DatePicker.svelte b/packages/bbui/src/Form/Core/DatePicker.svelte index d75350d8e8..1a7ab59818 100644 --- a/packages/bbui/src/Form/Core/DatePicker.svelte +++ b/packages/bbui/src/Form/Core/DatePicker.svelte @@ -67,6 +67,13 @@ // If time only set date component to 2000-01-01 if (timeOnly) { + // Classic flackpickr causing issues. + // When selecting a value for the first time for a "time only" field, + // the time is always offset by 1 hour for some reason (regardless of time + // zone) so we need to correct it. + if (!value && newValue) { + newValue = new Date(dates[0].getTime() + 60 * 60 * 1000).toISOString() + } newValue = `2000-01-01T${newValue.split("T")[1]}` } diff --git a/packages/bbui/src/Form/Core/Dropzone.svelte b/packages/bbui/src/Form/Core/Dropzone.svelte index ffdac08402..3102972d1e 100644 --- a/packages/bbui/src/Form/Core/Dropzone.svelte +++ b/packages/bbui/src/Form/Core/Dropzone.svelte @@ -139,7 +139,13 @@
{#if selectedUrl} - {selectedImage.name} + + {selectedImage.name} + {:else} {selectedImage.name} {/if} diff --git a/packages/bbui/src/Form/Core/RadioGroup.svelte b/packages/bbui/src/Form/Core/RadioGroup.svelte index 18a1e82ee8..a3952a9759 100644 --- a/packages/bbui/src/Form/Core/RadioGroup.svelte +++ b/packages/bbui/src/Form/Core/RadioGroup.svelte @@ -10,6 +10,7 @@ export let disabled = false export let getOptionLabel = option => option export let getOptionValue = option => option + export let getOptionTitle = option => option const dispatch = createEventDispatcher() const onChange = e => dispatch("change", e.target.value) @@ -19,7 +20,7 @@ {#if options && Array.isArray(options)} {#each options as option}
diff --git a/packages/bbui/src/Form/RadioGroup.svelte b/packages/bbui/src/Form/RadioGroup.svelte index 528f9f5eba..843a3657b4 100644 --- a/packages/bbui/src/Form/RadioGroup.svelte +++ b/packages/bbui/src/Form/RadioGroup.svelte @@ -12,6 +12,7 @@ export let direction = "vertical" export let getOptionLabel = option => extractProperty(option, "label") export let getOptionValue = option => extractProperty(option, "value") + export let getOptionTitle = option => extractProperty(option, "label") const dispatch = createEventDispatcher() const onChange = e => { @@ -35,6 +36,7 @@ {direction} {getOptionLabel} {getOptionValue} + {getOptionTitle} on:change={onChange} /> diff --git a/packages/bbui/src/Link/Link.svelte b/packages/bbui/src/Link/Link.svelte index f66554bd75..3bbfdd8282 100644 --- a/packages/bbui/src/Link/Link.svelte +++ b/packages/bbui/src/Link/Link.svelte @@ -8,12 +8,14 @@ export let secondary = false export let overBackground = false export let target + export let download
+
{attachment.extension}
{:else}
- + {attachment.extension}
diff --git a/packages/builder/cypress/integration/appPublishWorkflow.spec.js b/packages/builder/cypress/integration/appPublishWorkflow.spec.js index edca7ee3af..0e3fbb191b 100644 --- a/packages/builder/cypress/integration/appPublishWorkflow.spec.js +++ b/packages/builder/cypress/integration/appPublishWorkflow.spec.js @@ -102,7 +102,7 @@ filterTests(['all'], () => { cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 6000 }) cy.wait(500) - cy.get(interact.APP_TABLE_STATUS, { timeout: 1000 }).eq(0).contains("Unpublished") + cy.get(interact.APP_TABLE_STATUS, { timeout: 10000 }).eq(0).contains("Unpublished") }) }) diff --git a/packages/builder/cypress/integration/createBinding.spec.js b/packages/builder/cypress/integration/createBinding.spec.js index 160f23d2d6..0c1ddf1e7d 100644 --- a/packages/builder/cypress/integration/createBinding.spec.js +++ b/packages/builder/cypress/integration/createBinding.spec.js @@ -10,7 +10,7 @@ filterTests(['smoke', 'all'], () => { it("should add a current user binding", () => { cy.searchAndAddComponent("Paragraph").then(() => { - addSettingBinding("text", "Current User._id") + addSettingBinding("text", ["Current User", "_id"], "Current User._id") }) }) @@ -28,7 +28,7 @@ filterTests(['smoke', 'all'], () => { const paramName = "foo" cy.createScreen(`/test/:${paramName}`) cy.searchAndAddComponent("Paragraph").then(componentId => { - addSettingBinding("text", `URL.${paramName}`) + addSettingBinding("text", ["URL", paramName], `URL.${paramName}`) // The builder preview pages don't have a real URL, so all we can do // is check that we were able to bind to the property, and that the // component exists on the page @@ -47,11 +47,13 @@ filterTests(['smoke', 'all'], () => { }) }) - const addSettingBinding = (setting, bindingText, clickOption = true) => { + const addSettingBinding = (setting, bindingCategories, bindingText, clickOption = true) => { cy.get(`[data-cy="setting-${setting}"] [data-cy=text-binding-button]`).click() + cy.get(".category-list li").contains(bindingCategories[0]) cy.get(".drawer").within(() => { if (clickOption) { - cy.contains(bindingText).click() + cy.get(".category-list li").contains(bindingCategories[0]).click() + cy.get("li.binding").contains(bindingCategories[1]).click() cy.get("textarea").should("have.value", `{{ ${bindingText} }}`) } else { cy.get("textarea").type(bindingText) diff --git a/packages/builder/cypress/integration/createComponents.spec.js b/packages/builder/cypress/integration/createComponents.spec.js index 649a77e442..e39ce4a4a8 100644 --- a/packages/builder/cypress/integration/createComponents.spec.js +++ b/packages/builder/cypress/integration/createComponents.spec.js @@ -20,7 +20,7 @@ filterTests(["all"], () => { //Use the tree to delete a selected component const deleteSelectedComponent = () => { cy.get( - ".nav-items-container .nav-item.selected .actions > div > .icon" + ".nav-item.selected .actions > div > .icon" ).click({ force: true, }) @@ -91,7 +91,7 @@ filterTests(["all"], () => { cy.searchAndAddComponent("Paragraph").then(componentId => { cy.get("[data-cy=setting-_instanceName] input").type(componentId).blur() cy.get( - ".nav-items-container .nav-item.selected .actions > div > .icon" + ".nav-item.selected .actions > div > .icon" ).click({ force: true, }) @@ -145,7 +145,7 @@ filterTests(["all"], () => { return testFieldFocusOnCreate(label) }) .then(() => { - cy.get(".nav-items-container .nav-item") + cy.get(".nav-item") .contains(formId) .click({ force: true }) deleteSelectedComponent() @@ -195,7 +195,7 @@ filterTests(["all"], () => { return testFocusOnCreate(label) }) .then(() => { - cy.get(".nav-items-container .nav-item") + cy.get(".nav-item") .contains(providerId) .click({ force: true }) deleteSelectedComponent() @@ -218,7 +218,7 @@ filterTests(["all"], () => { .find(".component-placeholder") .should("not.exist") cy.getComponent(imageId).find(`img[alt=${imageId}]`).should("exist") - cy.get(".nav-items-container .nav-item") + cy.get(".nav-item") .contains(imageId) .click({ force: true }) deleteSelectedComponent() @@ -242,7 +242,7 @@ filterTests(["all"], () => { cy.getComponent(markdownId) .find(".editor-preview-full h1") .contains("Hi") - cy.get(".nav-items-container .nav-item") + cy.get(".nav-item") .contains(markdownId) .click({ force: true }) deleteSelectedComponent() @@ -265,7 +265,7 @@ filterTests(["all"], () => { .find(".component-placeholder") .should("not.exist") cy.getComponent(iconId).find("i.ri-save-fill").should("exist") - cy.get(".nav-items-container .nav-item") + cy.get(".nav-item") .contains(iconId) .click({ force: true }) deleteSelectedComponent() diff --git a/packages/builder/cypress/integration/datasources/mySql.spec.js b/packages/builder/cypress/integration/datasources/mySql.spec.js index 86b255ff58..654705a24e 100644 --- a/packages/builder/cypress/integration/datasources/mySql.spec.js +++ b/packages/builder/cypress/integration/datasources/mySql.spec.js @@ -175,7 +175,10 @@ filterTests(["all"], () => { cy.get("@query").its("response.statusCode").should("eq", 200) cy.get("@query").its("response.body").should("not.be.empty") // Save query + cy.intercept("POST", "**/queries").as("saveQuery") cy.get(".spectrum-Button").contains("Save Query").click({ force: true }) + cy.wait("@saveQuery") + cy.get("@saveQuery").its("response.statusCode").should("eq", 200) cy.get(".nav-item").should("contain", queryName) }) diff --git a/packages/builder/cypress/integration/datasources/postgreSql.spec.js b/packages/builder/cypress/integration/datasources/postgreSql.spec.js index feb583c83e..582aa1cd2a 100644 --- a/packages/builder/cypress/integration/datasources/postgreSql.spec.js +++ b/packages/builder/cypress/integration/datasources/postgreSql.spec.js @@ -162,7 +162,7 @@ filterTests(["all"], () => { switchSchema("randomText") // No tables displayed - cy.get(".spectrum-Body", { timeout: 5000 }).eq(2).should("contain", "No tables found") + cy.get(".spectrum-Body", { timeout: 20000 }).eq(2).should("contain", "No tables found") // Previously created query should be visible cy.get(".spectrum-Table").should("contain", queryName) @@ -173,7 +173,7 @@ filterTests(["all"], () => { switchSchema("1") // Confirm tables exist - Check for specific one - cy.get(".spectrum-Table", { timeout: 5000 }).eq(0).should("contain", "test") + cy.get(".spectrum-Table", { timeout: 20000 }).eq(0).should("contain", "test") cy.get(".spectrum-Table") .eq(0) .find(".spectrum-Table-row") @@ -187,7 +187,7 @@ filterTests(["all"], () => { switchSchema("public") // Confirm tables exist - again - cy.get(".spectrum-Table", { timeout: 5000 }).eq(0).should("contain", "REGIONS") + cy.get(".spectrum-Table", { timeout: 20000 }).eq(0).should("contain", "REGIONS") cy.get(".spectrum-Table") .eq(0) .find(".spectrum-Table-row") @@ -252,7 +252,8 @@ filterTests(["all"], () => { .contains("Delete Query") .click({ force: true }) // Confirm deletion - cy.reload({ timeout: 5000 }) + cy.reload() + cy.get(".nav-item", { timeout: 30000 }).contains(datasource).click({ force: true }) cy.get(".nav-item", { timeout: 1000 }).should("not.contain", queryRename) }) diff --git a/packages/builder/cypress/integration/revertApp.spec.js b/packages/builder/cypress/integration/revertApp.spec.js index 9a3d17f7c3..0fb58e89e9 100644 --- a/packages/builder/cypress/integration/revertApp.spec.js +++ b/packages/builder/cypress/integration/revertApp.spec.js @@ -48,6 +48,7 @@ filterTests(['smoke', 'all'], () => { cy.get(interact.AREA_LABEL_REVERT).click({ force: true }) }) cy.get(interact.SPECTRUM_DIALOG_GRID).within(() => { + cy.get("input").type("Cypress Tests") // Click Revert cy.get(interact.SPECTRUM_BUTTON).contains("Revert").click({ force: true }) cy.wait(2000) // Wait for app to finish reverting diff --git a/packages/builder/cypress/support/commands.js b/packages/builder/cypress/support/commands.js index acb56a0bce..394d5e9af2 100644 --- a/packages/builder/cypress/support/commands.js +++ b/packages/builder/cypress/support/commands.js @@ -4,7 +4,7 @@ Cypress.on("uncaught:exception", () => { // ACCOUNTS & USERS Cypress.Commands.add("login", (email, password) => { - cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 10000 }) + cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 30000 }) cy.url() .should("include", "/builder/") .then(url => { @@ -33,7 +33,7 @@ Cypress.Commands.add("login", (email, password) => { }) Cypress.Commands.add("logOut", () => { - cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 2000 }) + cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 30000 }) cy.get(".user-dropdown .avatar > .icon").click({ force: true }) cy.get(".spectrum-Popover[data-cy='user-menu']").within(() => { cy.get("li[data-cy='user-logout']").click({ force: true }) @@ -43,7 +43,7 @@ Cypress.Commands.add("logOut", () => { Cypress.Commands.add("logoutNoAppGrid", () => { // Logs user out when app grid is not present - cy.visit(`${Cypress.config().baseUrl}/builder`) + cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 30000 }) cy.get(".avatar > .icon").click({ force: true }) cy.get(".spectrum-Popover[data-cy='user-menu']").within(() => { cy.get(".spectrum-Menu-item").contains("Log out").click({ force: true }) @@ -68,11 +68,14 @@ Cypress.Commands.add("createUser", (email, permission) => { .click({ force: true }) }) } - // Add user and wait for modal to change - cy.get(".spectrum-Button").contains("Add user").click({ force: true }) + // Add user + cy.get(".spectrum-Button").contains("Add users").click({ force: true }) cy.get(".spectrum-ActionButton").contains("Add email").should("not.exist") }) // Onboarding modal + cy.get(".spectrum-Dialog-grid", { timeout: 5000 }).contains( + "Choose your onboarding" + ) cy.get(".spectrum-Dialog-grid").within(() => { cy.get(".onboarding-type").eq(1).click() cy.get(".spectrum-Button").contains("Done").click({ force: true }) @@ -163,7 +166,7 @@ Cypress.Commands.add("createApp", (name, addDefaultTable) => { const shouldCreateDefaultTable = typeof addDefaultTable != "boolean" ? true : addDefaultTable - cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 10000 }) + cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 30000 }) cy.url({ timeout: 30000 }).should("include", "/apps") cy.get(`[data-cy="create-app-btn"]`, { timeout: 5000 }).click({ force: true }) @@ -197,7 +200,7 @@ Cypress.Commands.add("createApp", (name, addDefaultTable) => { }) Cypress.Commands.add("deleteApp", name => { - cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 5000 }) + cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 30000 }) cy.wait(2000) cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`) .its("body") @@ -254,7 +257,7 @@ Cypress.Commands.add("deleteApp", name => { }) Cypress.Commands.add("deleteAllApps", () => { - cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 5000 }) + cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 30000 }) cy.wait(500) cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`, { timeout: 5000, @@ -351,7 +354,7 @@ Cypress.Commands.add("alterAppVersion", (appId, version) => { }) Cypress.Commands.add("importApp", (exportFilePath, name) => { - cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 5000 }) + cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 30000 }) cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`) .its("body") @@ -386,7 +389,7 @@ Cypress.Commands.add("importApp", (exportFilePath, name) => { // Filters visible with 1 or more Cypress.Commands.add("searchForApplication", appName => { - cy.visit(`${Cypress.config().baseUrl}/builder`) + cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 30000 }) cy.wait(2000) // No app filter functionality if only 1 app exists @@ -409,7 +412,7 @@ Cypress.Commands.add("searchForApplication", appName => { // Assumes there are no others Cypress.Commands.add("applicationInAppTable", appName => { - cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 10000 }) + cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 30000 }) cy.get(".appTable", { timeout: 5000 }).within(() => { cy.get(".title").contains(appName).should("exist") }) @@ -448,10 +451,7 @@ Cypress.Commands.add("createTable", (tableName, initialTable) => { .contains("Continue") .click({ force: true }) }) - cy.get(".spectrum-Modal", { timeout: 10000 }).should( - "not.contain", - "Add data source" - ) + cy.get(".spectrum-Modal").contains("Create Table", { timeout: 10000 }) cy.get(".spectrum-Modal", { timeout: 2000 }).within(() => { cy.get("input", { timeout: 2000 }).first().type(tableName).blur() cy.get(".spectrum-ButtonGroup").contains("Create").click() @@ -742,8 +742,15 @@ Cypress.Commands.add("deleteAllScreens", () => { Cypress.Commands.add("navigateToFrontend", () => { // Clicks on Design tab and then the Home nav item cy.wait(500) + cy.intercept("**/preview").as("preview") cy.contains("Design").click() - cy.get(".spectrum-Search", { timeout: 2000 }).type("/") + cy.wait("@preview") + cy.get("@preview").then(res => { + if (res.statusCode != 200) { + cy.reload() + } + }) + cy.get(".spectrum-Search", { timeout: 20000 }).type("/") cy.get(".nav-item", { timeout: 2000 }).contains("home").click({ force: true }) }) diff --git a/packages/builder/package.json b/packages/builder/package.json index 3d4a7f8417..ac1b15510a 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/builder", - "version": "1.2.44-alpha.6", + "version": "1.3.4-alpha.2", "license": "GPL-3.0", "private": true, "scripts": { @@ -69,10 +69,10 @@ } }, "dependencies": { - "@budibase/bbui": "1.2.44-alpha.6", - "@budibase/client": "1.2.44-alpha.6", - "@budibase/frontend-core": "1.2.44-alpha.6", - "@budibase/string-templates": "1.2.44-alpha.6", + "@budibase/bbui": "1.3.4-alpha.2", + "@budibase/client": "1.3.4-alpha.2", + "@budibase/frontend-core": "1.3.4-alpha.2", + "@budibase/string-templates": "1.3.4-alpha.2", "@sentry/browser": "5.19.1", "@spectrum-css/page": "^3.0.1", "@spectrum-css/vars": "^3.0.1", diff --git a/packages/builder/src/builderStore/dataBinding.js b/packages/builder/src/builderStore/dataBinding.js index 13b749e19f..d961a3a1cd 100644 --- a/packages/builder/src/builderStore/dataBinding.js +++ b/packages/builder/src/builderStore/dataBinding.js @@ -299,7 +299,10 @@ const getProviderContextBindings = (asset, dataProviders) => { schema = {} const values = context.values || [] values.forEach(value => { - schema[value.key] = { name: value.label, type: "string" } + schema[value.key] = { + name: value.label, + type: value.type || "string", + } }) } else if (context.type === "schema") { // Schema contexts are generated dynamically depending on their data @@ -359,6 +362,12 @@ const getProviderContextBindings = (asset, dataProviders) => { providerId, // Table ID is used by JSON fields to know what table the field is in tableId: table?._id, + category: component._instanceName, + icon: def.icon, + display: { + name: fieldSchema.name || key, + type: fieldSchema.type, + }, }) }) }) @@ -385,6 +394,9 @@ const getUserBindings = () => { // datasource options, based on bindable properties fieldSchema, providerId: "user", + category: "Current User", + icon: "User", + display: fieldSchema, }) }) return bindings @@ -401,11 +413,17 @@ const getDeviceBindings = () => { type: "context", runtimeBinding: `${safeDevice}.${makePropSafe("mobile")}`, readableBinding: `Device.Mobile`, + category: "Device", + icon: "DevicePhone", + display: { type: "boolean", name: "mobile" }, }) bindings.push({ type: "context", runtimeBinding: `${safeDevice}.${makePropSafe("tablet")}`, readableBinding: `Device.Tablet`, + category: "Device", + icon: "DevicePhone", + display: { type: "boolean", name: "tablet" }, }) } return bindings @@ -429,6 +447,8 @@ const getSelectedRowsBindings = asset => { "selectedRows" )}`, readableBinding: `${table._instanceName}.Selected rows`, + category: "Selected rows", + icon: "ViewRow", })) ) @@ -460,6 +480,9 @@ const getStateBindings = () => { type: "context", runtimeBinding: `${safeState}.${makePropSafe(key)}`, readableBinding: `State.${key}`, + category: "State", + icon: "AutomatedSegment", + display: { name: key }, })) } return bindings @@ -482,11 +505,17 @@ const getUrlBindings = asset => { type: "context", runtimeBinding: `${safeURL}.${makePropSafe(param)}`, readableBinding: `URL.${param}`, + category: "URL", + icon: "RailTop", + display: { type: "string" }, })) const queryParamsBinding = { type: "context", runtimeBinding: makePropSafe("query"), readableBinding: "Query params", + category: "URL", + icon: "RailTop", + display: { type: "object" }, } return urlParamBindings.concat([queryParamsBinding]) } @@ -497,6 +526,9 @@ const getRoleBindings = () => { type: "context", runtimeBinding: `trim "${role._id}"`, readableBinding: `Role.${role.name}`, + category: "Role", + icon: "UserGroup", + display: { type: "string", name: role.name }, } }) } @@ -518,6 +550,7 @@ export const getEventContextBindings = ( // Check if any context bindings are provided by the component for this // setting const component = findComponent(asset.props, componentId) + const def = store.actions.components.getDefinition(component?._component) const settings = getComponentSettings(component?._component) const eventSetting = settings.find(setting => setting.key === settingKey) if (eventSetting?.context?.length) { @@ -527,6 +560,8 @@ export const getEventContextBindings = ( runtimeBinding: `${makePropSafe("eventContext")}.${makePropSafe( contextEntry.key )}`, + category: component._instanceName, + icon: def.icon, }) }) } @@ -548,6 +583,8 @@ export const getEventContextBindings = ( bindings.push({ readableBinding: `Action ${idx + 1}.${contextValue.label}`, runtimeBinding: `actions.${idx}.${contextValue.value}`, + category: "Actions", + icon: "JourneyAction", }) }) } diff --git a/packages/builder/src/builderStore/store/frontend.js b/packages/builder/src/builderStore/store/frontend.js index 0c52e68296..933695d0ec 100644 --- a/packages/builder/src/builderStore/store/frontend.js +++ b/packages/builder/src/builderStore/store/frontend.js @@ -19,7 +19,6 @@ import { makeComponentUnique, } from "../componentUtils" import { Helpers } from "@budibase/bbui" -import { DefaultAppTheme, LAYOUT_NAMES } from "../../constants" import { Utils } from "@budibase/frontend-core" const INITIAL_FRONTEND_STATE = { @@ -125,35 +124,6 @@ export const getFrontendStore = () => { await integrations.init() await queries.init() await tables.init() - - // Add navigation settings to old apps - if (!application.navigation) { - const layout = layouts.find(x => x._id === LAYOUT_NAMES.MASTER.PRIVATE) - const customTheme = application.customTheme - let navigationSettings = { - navigation: "Top", - title: application.name, - navWidth: "Large", - navBackground: - customTheme?.navBackground || DefaultAppTheme.navBackground, - navTextColor: - customTheme?.navTextColor || DefaultAppTheme.navTextColor, - } - if (layout) { - navigationSettings.hideLogo = layout.props.hideLogo - navigationSettings.hideTitle = layout.props.hideTitle - navigationSettings.title = layout.props.title || application.name - navigationSettings.logoUrl = layout.props.logoUrl - navigationSettings.links = layout.props.links - navigationSettings.navigation = layout.props.navigation || "Top" - navigationSettings.sticky = layout.props.sticky - navigationSettings.navWidth = layout.props.width || "Large" - if (navigationSettings.navigation === "None") { - navigationSettings.navigation = "Top" - } - } - await store.actions.navigation.save(navigationSettings) - } }, theme: { save: async theme => { diff --git a/packages/builder/src/components/automation/AutomationPanel/AutomationList.svelte b/packages/builder/src/components/automation/AutomationPanel/AutomationList.svelte index 53b0603ebb..c7bd43661b 100644 --- a/packages/builder/src/components/automation/AutomationPanel/AutomationList.svelte +++ b/packages/builder/src/components/automation/AutomationPanel/AutomationList.svelte @@ -23,7 +23,7 @@
- {#each $automationStore.automations as automation, idx} + {#each $automationStore.automations.sort(aut => aut.name) as automation, idx} 0} icon="ShareAndroid" diff --git a/packages/builder/src/components/automation/SetupPanel/FieldSelector.svelte b/packages/builder/src/components/automation/SetupPanel/FieldSelector.svelte index f510d961fb..3920885a2e 100644 --- a/packages/builder/src/components/automation/SetupPanel/FieldSelector.svelte +++ b/packages/builder/src/components/automation/SetupPanel/FieldSelector.svelte @@ -14,7 +14,7 @@ $: { let fields = {} - for (const [key, type] of Object.entries(block?.inputs?.fields)) { + for (const [key, type] of Object.entries(block?.inputs?.fields ?? {})) { fields = { ...fields, [key]: { diff --git a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte index ce2b97bcba..21059b32dd 100644 --- a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte +++ b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte @@ -467,6 +467,7 @@ options={relationshipOptions} getOptionLabel={option => option.name} getOptionValue={option => option.value} + getOptionTitle={option => option.alt} /> {/if} { + if (ele.icon) { + acc[ele.category] = acc[ele.category] || ele.icon + } + return acc + }, {}) + + $: categoryIcons = { ...bindingIcons, Helpers: "MagicWand" } + $: filteredCategories = categories .map(([name, categoryBindings]) => ({ name, @@ -55,10 +74,19 @@ return binding.readableBinding.match(searchRgx) }), })) - .filter(category => category.bindings?.length > 0) + .filter(category => { + return ( + category.bindings?.length > 0 && + (!selectedCategory ? true : selectedCategory === category.name) + ) + }) + $: filteredHelpers = helpers?.filter(helper => { return helper.label.match(searchRgx) || helper.description.match(searchRgx) }) + + $: categoryNames = [...categories.map(cat => cat[0]), "Helpers"] + $: codeMirrorHints = bindings?.map(x => `$("${x.readableBinding}")`) const updateValue = val => { @@ -140,58 +168,163 @@ }) + + + +
+ {#if hoverTarget.title} +
{hoverTarget.title}
+ {/if} + {#if hoverTarget.description} +
+ {@html hoverTarget.description} +
+ {/if} + {#if hoverTarget.example} +
{hoverTarget.example}
+ {/if} +
+
+
+
+ -
-
+ + {#if selectedCategory} +
+ { + selectedCategory = null + }} + > + Back + +
+ {/if} + + {#if !selectedCategory}
Search
-
- {#each filteredCategories as category} - {#if category.bindings?.length} -
-
{category.name}
+ {/if} + + {#if !selectedCategory && !search} +
    + {#each categoryNames as categoryName} +
  • { + selectedCategory = categoryName + }} + > + + {categoryName} + +
  • + {/each} +
+ {/if} + + {#if selectedCategory || search} + {#each filteredCategories as category} + {#if category.bindings?.length} +
+ {category.name} +
    {#each category.bindings as binding} -
  • addBinding(binding)}> - {binding.readableBinding} - {#if binding.type} - {binding.type} - {/if} - {#if binding.description} -
    -
    - {binding.description || ""} -
    +
  • { + popoverAnchor = e.target + if (!binding.description) { + return + } + hoverTarget = { + title: binding.display.name || binding.fieldSchema.name, + description: binding.description, + } + popover.show() + e.stopPropagation() + }} + on:mouseleave={() => { + popover.hide() + popoverAnchor = null + hoverTarget = null + }} + on:focus={() => {}} + on:blur={() => {}} + on:click={() => addBinding(binding)} + > + + {#if binding.display?.name} + {binding.display.name} + {:else if binding.fieldSchema?.name} + {binding.fieldSchema?.name} + {:else} + {binding.readableBinding} + {/if} + + + {#if binding.display?.type || binding.fieldSchema?.type} + + + {binding.display?.type || binding.fieldSchema?.type} + + {/if}
  • {/each}
-
+ {/if} + {/each} + + {#if selectedCategory === "Helpers" || search} + {#if filteredHelpers?.length} +
Helpers
+
    + {#each filteredHelpers as helper} +
  • addHelper(helper, usingJS)} + on:mouseenter={e => { + popoverAnchor = e.target + if (!helper.displayText && helper.description) { + return + } + hoverTarget = { + title: helper.displayText, + description: helper.description, + example: getHelperExample(helper, usingJS), + } + popover.show() + e.stopPropagation() + }} + on:mouseleave={() => { + popover.hide() + popoverAnchor = null + hoverTarget = null + }} + on:focus={() => {}} + on:blur={() => {}} + > + {helper.displayText} + + function + +
  • + {/each} +
+ {/if} {/if} - {/each} - {#if filteredHelpers?.length} -
-
Helpers
-
    - {#each filteredHelpers as helper} -
  • addHelper(helper, usingJS)}> -
    -
    {helper.displayText}
    -
    - {@html helper.description} -
    -
    {getHelperExample(
    -                      helper,
    -                      usingJS
    -                    )}
    -
    -
  • - {/each} -
-
{/if} -
+
@@ -241,6 +374,35 @@ diff --git a/packages/builder/src/pages/builder/portal/manage/users/_components/ImportUsersModal.svelte b/packages/builder/src/pages/builder/portal/manage/users/_components/ImportUsersModal.svelte index 1e7c579346..d6ea4275c9 100644 --- a/packages/builder/src/pages/builder/portal/manage/users/_components/ImportUsersModal.svelte +++ b/packages/builder/src/pages/builder/portal/manage/users/_components/ImportUsersModal.svelte @@ -62,7 +62,7 @@ csvString = e.target.result files = fileArray - userEmails = csvString.split("\n") + userEmails = csvString.split(/\r?\n/) }) reader.readAsText(fileArray[0]) } diff --git a/packages/builder/src/pages/builder/portal/manage/users/_components/InvitedModal.svelte b/packages/builder/src/pages/builder/portal/manage/users/_components/InvitedModal.svelte new file mode 100644 index 0000000000..9cc66a1385 --- /dev/null +++ b/packages/builder/src/pages/builder/portal/manage/users/_components/InvitedModal.svelte @@ -0,0 +1,75 @@ + + + + {#if hasSuccess} + + Your users should now receive an email invite to get access to their + Budibase account + + {/if} + {#if hasFailure} + + {failureMessage} + + + {/if} + + + diff --git a/packages/builder/src/pages/builder/portal/manage/users/_components/PasswordModal.svelte b/packages/builder/src/pages/builder/portal/manage/users/_components/PasswordModal.svelte index 02501f2de0..990a54610c 100644 --- a/packages/builder/src/pages/builder/portal/manage/users/_components/PasswordModal.svelte +++ b/packages/builder/src/pages/builder/portal/manage/users/_components/PasswordModal.svelte @@ -2,24 +2,78 @@ import { Body, ModalContent, Table, Icon } from "@budibase/bbui" import PasswordCopyRenderer from "./PasswordCopyRenderer.svelte" import { parseToCsv } from "helpers/data/utils" + import { onMount } from "svelte" export let userData + export let createUsersResponse - $: mappedData = userData.map(user => { - return { - email: user.email, - password: user.password, + let hasSuccess + let hasFailure + let title + let failureMessage + + let userDataIndex + let successfulUsers + let unsuccessfulUsers + + const setTitle = () => { + if (hasSuccess) { + title = "Users created!" + } else if (hasFailure) { + title = "Oops!" } + } + + const setFailureMessage = () => { + if (hasSuccess) { + failureMessage = "However there was a problem creating some users." + } else { + failureMessage = "There was a problem creating some users." + } + } + + const setUsers = () => { + userDataIndex = userData.reduce((prev, current) => { + prev[current.email] = current + return prev + }, {}) + + successfulUsers = createUsersResponse.successful.map(user => { + return { + email: user.email, + password: userDataIndex[user.email].password, + } + }) + + unsuccessfulUsers = createUsersResponse.unsuccessful.map(user => { + return { + email: user.email, + reason: user.reason, + } + }) + } + + onMount(() => { + hasSuccess = createUsersResponse.successful.length + hasFailure = createUsersResponse.unsuccessful.length + setTitle() + setFailureMessage() + setUsers() }) - const schema = { + const successSchema = { email: {}, password: {}, } + const failedSchema = { + email: {}, + reason: {}, + } + const downloadCsvFile = () => { const fileName = "passwords.csv" - const content = parseToCsv(["email", "password"], mappedData) + const content = parseToCsv(["email", "password"], successfulUsers) download(fileName, content) } @@ -42,36 +96,52 @@ - - All your new users can be accessed through the autogenerated passwords. Take - note of these passwords or download the CSV file. - + {#if hasFailure} + + {failureMessage} + +
+ {/if} + {#if hasSuccess} + + All your new users can be accessed through the autogenerated passwords. + Take note of these passwords or download the CSV file. + -
-
- +
+
+ -
- Passwords CSV +
+ Passwords CSV +
-
-
+
+ {/if}