diff --git a/.github/workflows/deploy-release.yml b/.github/workflows/deploy-release.yml index b3385c2ccd..b37ff9cee8 100644 --- a/.github/workflows/deploy-release.yml +++ b/.github/workflows/deploy-release.yml @@ -68,16 +68,28 @@ jobs: ] env: KUBECONFIG_FILE: '${{ secrets.RELEASE_KUBECONFIG }}' - - - name: Set the base64 kubeconfig - run: echo 'RELEASE_KUBECONFIG=${{ secrets.RELEASE_KUBECONFIG }}' | base64 - - name: Re roll the services + - name: Re roll app-service uses: actions-hub/kubectl@master env: - KUBE_CONFIG: ${{ env.RELEASE_KUBECONFIG }} + KUBE_CONFIG: ${{ secrets.RELEASE_KUBECONFIG_BASE64 }} with: - args: rollout restart deployment proxy-service -n budibase && kubectl rollout restart deployment app-service -n budibase && kubectl rollout restart deployment worker-service -n budibase + args: rollout restart deployment app-service -n budibase + + - name: Re roll proxy-service + uses: actions-hub/kubectl@master + env: + KUBE_CONFIG: ${{ secrets.RELEASE_KUBECONFIG_BASE64 }} + with: + args: rollout restart deployment proxy-service -n budibase + + - name: Re roll worker-service + uses: actions-hub/kubectl@master + env: + KUBE_CONFIG: ${{ secrets.RELEASE_KUBECONFIG_BASE64 }} + with: + args: rollout restart deployment worker-service -n budibase + - name: Discord Webhook Action uses: tsickert/discord-webhook@v4.0.0 diff --git a/.github/workflows/release-develop.yml b/.github/workflows/release-develop.yml index 8d3e9f4021..57e65c734e 100644 --- a/.github/workflows/release-develop.yml +++ b/.github/workflows/release-develop.yml @@ -121,15 +121,26 @@ jobs: env: KUBECONFIG_FILE: '${{ secrets.RELEASE_KUBECONFIG }}' - - name: Set the base64 kubeconfig - run: echo 'RELEASE_KUBECONFIG=${{ secrets.RELEASE_KUBECONFIG }}' | base64 - - - name: Re roll the services + - name: Re roll app-service uses: actions-hub/kubectl@master env: - KUBE_CONFIG: ${{ env.RELEASE_KUBECONFIG }} + KUBE_CONFIG: ${{ secrets.RELEASE_KUBECONFIG_BASE64 }} with: - args: rollout restart deployment proxy-service -n budibase && kubectl rollout restart deployment app-service -n budibase && kubectl rollout restart deployment worker-service -n budibase + args: rollout restart deployment app-service -n budibase + + - name: Re roll proxy-service + uses: actions-hub/kubectl@master + env: + KUBE_CONFIG: ${{ secrets.RELEASE_KUBECONFIG_BASE64 }} + with: + args: rollout restart deployment proxy-service -n budibase + + - name: Re roll worker-service + uses: actions-hub/kubectl@master + env: + KUBE_CONFIG: ${{ secrets.RELEASE_KUBECONFIG_BASE64 }} + with: + args: rollout restart deployment worker-service -n budibase - name: Discord Webhook Action uses: tsickert/discord-webhook@v4.0.0 diff --git a/.github/workflows/smoke_test.yaml b/.github/workflows/smoke_test.yaml index 7002c8335b..cffb914aaf 100644 --- a/.github/workflows/smoke_test.yaml +++ b/.github/workflows/smoke_test.yaml @@ -1,4 +1,4 @@ -name: Budibase Smoke Test +name: Budibase Nightly Tests on: workflow_dispatch: @@ -6,7 +6,7 @@ on: - cron: "0 5 * * *" # every day at 5AM jobs: - release: + nightly: runs-on: ubuntu-latest steps: @@ -43,6 +43,18 @@ jobs: name: Test Reports path: packages/builder/cypress/reports/testReport.html + # TODO: enable once running in QA test env + # - name: Configure AWS Credentials + # uses: aws-actions/configure-aws-credentials@v1 + # with: + # aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + # aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + # aws-region: eu-west-1 + + # - name: Upload test results HTML + # uses: aws-actions/configure-aws-credentials@v1 + # run: aws s3 cp packages/builder/cypress/reports/testReport.html s3://{{ secrets.BUDI_QA_REPORTS_BUCKET_NAME }}/$GITHUB_RUN_ID/index.html + - name: Cypress Discord Notify run: yarn test:e2e:ci:notify env: 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..74b98ac008 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 diff --git a/charts/budibase/templates/worker-service-deployment.yaml b/charts/budibase/templates/worker-service-deployment.yaml index 918dab427b..083231eeff 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: diff --git a/charts/budibase/values.yaml b/charts/budibase/values.yaml index 404e92c70f..9b5e76d0d7 100644 --- a/charts/budibase/values.yaml +++ b/charts/budibase/values.yaml @@ -114,6 +114,10 @@ globals: smtp: enabled: false +# elasticApmEnabled: +# elasticApmSecretToken: +# elasticApmServerUrl: + services: budibaseVersion: latest dns: cluster.local @@ -126,6 +130,7 @@ services: port: 4002 replicaCount: 1 logLevel: info +# nodeDebug: "" # set the value of NODE_DEBUG worker: port: 4003 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 4213626309..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"; @@ -85,6 +88,10 @@ http { proxy_pass http://$apps:4002; } + location /preview { + proxy_pass http://$apps:4002; + } + location = / { proxy_pass http://$apps:4002; } @@ -94,6 +101,7 @@ http { proxy_pass http://$watchtower:8080; } {{/if}} + location ~ ^/(builder|app_) { proxy_http_version 1.1; proxy_set_header Connection $connection_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 28379208fe..70a4e77a28 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "1.2.44-alpha.1", + "version": "1.3.4-alpha.1", "npmClient": "yarn", "packages": [ "packages/*" diff --git a/packages/backend-core/package.json b/packages/backend-core/package.json index b95a33703d..b5765805cd 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.1", + "version": "1.3.4-alpha.1", "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.1", + "@budibase/types": "1.3.4-alpha.1", "@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 99f58a045b..181c2ac0a8 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.1", + "version": "1.3.4-alpha.1", "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.1", + "@budibase/string-templates": "1.3.4-alpha.1", "@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/adminAndManagement/authentication.spec.js b/packages/builder/cypress/integration/adminAndManagement/authentication.spec.js new file mode 100644 index 0000000000..5cc42cb59a --- /dev/null +++ b/packages/builder/cypress/integration/adminAndManagement/authentication.spec.js @@ -0,0 +1,178 @@ +import filterTests from "../../support/filterTests" +// const interact = require("../support/interact") + +filterTests(["smoke", "all"], () => { + context("Auth Configuration", () => { + before(() => { + cy.login() + }) + + after(() => { + cy.get(".spectrum-SideNav li").contains("Auth").click() + cy.location().should(loc => { + expect(loc.pathname).to.eq("/builder/portal/manage/auth") + }) + + cy.get("[data-cy=new-scope-input]").clear() + + cy.get("div.content").scrollTo("bottom") + cy.get("[data-cy=oidc-active]").click() + + cy.get("[data-cy=oidc-active]").should('not.be.checked') + + cy.intercept("POST", "/api/global/configs").as("updateAuth") + cy.get("button[data-cy=oidc-save]").contains("Save").click({force: true}) + cy.wait("@updateAuth") + cy.get("@updateAuth").its("response.statusCode").should("eq", 200) + + cy.get(".spectrum-Toast-content") + .contains("Settings saved") + .should("be.visible") + }) + + it("Should allow updating of the OIDC config", () => { + cy.get(".spectrum-SideNav li").contains("Auth").click() + cy.location().should(loc => { + expect(loc.pathname).to.eq("/builder/portal/manage/auth") + }) + cy.get("div.content").scrollTo("bottom") + cy.get(".spectrum-Toast .spectrum-ClearButton").click() + + cy.get("input[data-cy=configUrl]").type("http://budi-auth.com/v2") + cy.get("input[data-cy=clientID]").type("34ac6a13-f24a-4b52-c70d-fa544ffd11b2") + cy.get("input[data-cy=clientSecret]").type("12A8Q~4nS_DWhOOJ2vWIRsNyDVsdtXPD.Zxa9df_") + + cy.get("button[data-cy=oidc-save]").should("not.be.disabled"); + + cy.intercept("POST", "/api/global/configs").as("updateAuth") + cy.get("button[data-cy=oidc-save]").contains("Save").click({force: true}) + cy.wait("@updateAuth") + cy.get("@updateAuth").its("response.statusCode").should("eq", 200) + + cy.get(".spectrum-Toast-content") + .contains("Settings saved") + .should("be.visible") + }) + + it("Should display default scopes in advanced config.", () => { + cy.get(".spectrum-SideNav li").contains("Auth").click() + cy.location().should(loc => { + expect(loc.pathname).to.eq("/builder/portal/manage/auth") + }) + cy.get("div.content").scrollTo("bottom") + + cy.get(".spectrum-Tags").find(".spectrum-Tags-item").its("length").should("eq", 4) + + cy.get(".spectrum-Tags-item").contains("openid") + cy.get(".spectrum-Tags-item").contains("openid").find(".spectrum-ClearButton").should("not.exist") + + cy.get(".spectrum-Tags-item").contains("offline_access") + cy.get(".spectrum-Tags-item").contains("email") + cy.get(".spectrum-Tags-item").contains("profile") + }) + + it("Add a new scopes", () => { + cy.get(".spectrum-SideNav li").contains("Auth").click() + cy.location().should(loc => { + expect(loc.pathname).to.eq("/builder/portal/manage/auth") + }) + cy.get("div.content").scrollTo("bottom") + + cy.get("[data-cy=new-scope-input]").type("Sample{enter}") + cy.get(".spectrum-Tags").find(".spectrum-Tags-item").its("length").should("eq", 5) + cy.get(".spectrum-Tags-item").contains("Sample") + + cy.get(".auth-form input.spectrum-Textfield-input").type("Another ") + cy.get(".spectrum-Tags").find(".spectrum-Tags-item").its("length").should("eq", 6) + cy.get(".spectrum-Tags-item").contains("Another") + + cy.get("button[data-cy=oidc-save]").should("not.be.disabled"); + + cy.intercept("POST", "/api/global/configs").as("updateAuth") + cy.get("button[data-cy=oidc-save]").contains("Save").click({force: true}) + cy.wait("@updateAuth") + cy.get("@updateAuth").its("response.statusCode").should("eq", 200) + + cy.reload() + + cy.get("div.content").scrollTo("bottom") + + cy.get(".spectrum-Tags-item").contains("openid") + cy.get(".spectrum-Tags-item").contains("offline_access") + cy.get(".spectrum-Tags-item").contains("email") + cy.get(".spectrum-Tags-item").contains("profile") + cy.get(".spectrum-Tags-item").contains("Sample") + cy.get(".spectrum-Tags-item").contains("Another") + }) + + it("Should allow the removal of auth scopes", () => { + cy.get(".spectrum-SideNav li").contains("Auth").click() + cy.location().should(loc => { + expect(loc.pathname).to.eq("/builder/portal/manage/auth") + }) + cy.get("div.content").scrollTo("bottom") + + cy.get(".spectrum-Tags-item").contains("offline_access").parent().find(".spectrum-ClearButton").click() + cy.get(".spectrum-Tags-item").contains("profile").parent().find(".spectrum-ClearButton").click() + + cy.get(".spectrum-Tags").find(".spectrum-Tags-item").its("length").should("eq", 4) + + cy.get(".spectrum-Tags-item").contains("offline_access").should("not.exist") + cy.get(".spectrum-Tags-item").contains("profile").should("not.exist") + + cy.get("button[data-cy=oidc-save]").should("not.be.disabled"); + + cy.intercept("POST", "/api/global/configs").as("updateAuth") + cy.get("button[data-cy=oidc-save]").contains("Save").click({force: true}) + cy.wait("@updateAuth") + cy.get("@updateAuth").its("response.statusCode").should("eq", 200) + + cy.get(".spectrum-Toast-content") + .contains("Settings saved") + .should("be.visible") + + cy.reload() + + cy.get(".spectrum-Tags").find(".spectrum-Tags-item").its("length").should("eq", 4) + + cy.get(".spectrum-Tags-item").contains("offline_access").should("not.exist") + cy.get(".spectrum-Tags-item").contains("profile").should("not.exist") + }) + + it("Should allow auth scopes to be reset to the core defaults.", () => { + cy.get(".spectrum-SideNav li").contains("Auth").click() + + cy.get("div.content").scrollTo("bottom") + + cy.get("[data-cy=restore-oidc-default-scopes]").click({force: true}) + + cy.get(".spectrum-Tags").find(".spectrum-Tags-item").its("length").should("eq", 4) + + cy.get(".spectrum-Tags-item").contains("openid") + cy.get(".spectrum-Tags-item").contains("offline_access") + cy.get(".spectrum-Tags-item").contains("email") + cy.get(".spectrum-Tags-item").contains("profile") + }) + + it("Should not allow invalid characters in the auth scopes", () => { + cy.get("[data-cy=new-scope-input]").type("thisIsInvalid\\{enter}") + cy.get(".spectrum-Form-itemField .error").contains("Auth scopes cannot contain spaces, double quotes or backslashes") + cy.get(".spectrum-Tags").find(".spectrum-Tags-item").its("length").should("eq", 4) + + cy.get("[data-cy=new-scope-input]").clear() + + cy.get("[data-cy=new-scope-input]").type("alsoInvalid\"{enter}") + cy.get(".spectrum-Form-itemField .error").contains("Auth scopes cannot contain spaces, double quotes or backslashes") + cy.get(".spectrum-Tags").find(".spectrum-Tags-item").its("length").should("eq", 4) + + cy.get("[data-cy=new-scope-input]").clear() + }) + + it("Should not allow duplicate auth scopes", () => { + cy.get("[data-cy=new-scope-input]").type("offline_access{enter}") + cy.get(".spectrum-Form-itemField .error").contains("Auth scope already exists") + cy.get(".spectrum-Tags").find(".spectrum-Tags-item").its("length").should("eq", 4) + }) + + }) +}) \ No newline at end of file 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/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..bfb312a0c8 100644 --- a/packages/builder/cypress/integration/datasources/postgreSql.spec.js +++ b/packages/builder/cypress/integration/datasources/postgreSql.spec.js @@ -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..d4b0ec80e8 100644 --- a/packages/builder/cypress/support/commands.js +++ b/packages/builder/cypress/support/commands.js @@ -448,10 +448,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 +739,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 569a6c1614..f66178cf58 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/builder", - "version": "1.2.44-alpha.1", + "version": "1.3.4-alpha.1", "license": "GPL-3.0", "private": true, "scripts": { @@ -13,11 +13,11 @@ "cy:setup:ci": "node ./cypress/setup.js", "cy:open": "cypress open", "cy:run": "cypress run", - "cy:run:ci": "cypress run --headed --browser chrome --spec cypress/integration/createApp.spec.js", + "cy:run:ci": "cypress run --headed --browser chrome --spec cypress/integration/createApp.spec.js", "cy:run:ci:record": "xvfb-run cypress run --headed --browser chrome --record", "cy:test": "start-server-and-test cy:setup http://localhost:4100/builder cy:run", "cy:ci": "start-server-and-test cy:setup:ci http://localhost:4100/builder cy:run:ci", - "cy:ci:record": "start-server-and-test cy:setup:ci http://localhost:4100/builder cy:run:ci:record && npm run cy:ci:report", + "cy:ci:record": "start-server-and-test cy:setup:ci http://localhost:4100/builder cy:run:ci:record; npm run cy:ci:report", "cy:ci:report": "mochawesome-merge cypress/reports/*.json > cypress/reports/testReport.json && marge cypress/reports/testReport.json --reportDir cypress/reports --inline", "cy:ci:notify": "node scripts/cypressResultsWebhook", "cy:debug": "start-server-and-test cy:setup http://localhost:4100/builder cy:open", @@ -69,10 +69,10 @@ } }, "dependencies": { - "@budibase/bbui": "1.2.44-alpha.1", - "@budibase/client": "1.2.44-alpha.1", - "@budibase/frontend-core": "1.2.44-alpha.1", - "@budibase/string-templates": "1.2.44-alpha.1", + "@budibase/bbui": "1.3.4-alpha.1", + "@budibase/client": "1.3.4-alpha.1", + "@budibase/frontend-core": "1.3.4-alpha.1", + "@budibase/string-templates": "1.3.4-alpha.1", "@sentry/browser": "5.19.1", "@spectrum-css/page": "^3.0.1", "@spectrum-css/vars": "^3.0.1", diff --git a/packages/builder/scripts/cypressResultsWebhook.js b/packages/builder/scripts/cypressResultsWebhook.js index 457093e013..4de4c01cc7 100644 --- a/packages/builder/scripts/cypressResultsWebhook.js +++ b/packages/builder/scripts/cypressResultsWebhook.js @@ -5,7 +5,6 @@ const path = require("path") const fs = require("fs") const WEBHOOK_URL = process.env.CYPRESS_WEBHOOK_URL -const OUTCOME = process.env.CYPRESS_OUTCOME const DASHBOARD_URL = process.env.CYPRESS_DASHBOARD_URL const GIT_SHA = process.env.GITHUB_SHA const GITHUB_ACTIONS_RUN_URL = process.env.GITHUB_ACTIONS_RUN_URL @@ -35,6 +34,8 @@ async function discordCypressResultsNotification(report) { skipped, } = report.stats + const OUTCOME = failures > 0 ? "failure" : "success" + const options = { method: "POST", headers: { @@ -114,7 +115,7 @@ async function discordCypressResultsNotification(report) { } const response = await fetch(WEBHOOK_URL, options) - if (response.status >= 400) { + if (response.status >= 201) { const text = await response.text() console.error( `Error sending discord webhook. \nStatus: ${response.status}. \nResponse Body: ${text}. \nRequest Body: ${options.body}` 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 c7795d4b54..c5d953021f 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 = { @@ -40,6 +39,7 @@ const INITIAL_FRONTEND_STATE = { devicePreview: false, messagePassing: false, continueIfAction: false, + showNotificationAction: false, }, errors: [], hasAppPackage: false, @@ -124,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}