This commit is contained in:
Martin McKeaveney 2022-02-01 11:22:14 +01:00
commit c11e26cb2e
224 changed files with 7610 additions and 3863 deletions

46
.github/workflows/smoke_test.yaml vendored Normal file
View File

@ -0,0 +1,46 @@
name: Budibase Smoke Test
on:
workflow_dispatch:
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Use Node.js 14.x
uses: actions/setup-node@v1
with:
node-version: 14.x
- run: yarn
- run: yarn bootstrap
- run: yarn build
- name: Pull cypress.env.yaml from budibase-infra
run: |
curl -H "Authorization: token ${{ secrets.GH_PERSONAL_TOKEN }}" \
-H 'Accept: application/vnd.github.v3.raw' \
-o packages/builder/cypress.env.json \
-L https://api.github.com/repos/budibase/budibase-infra/contents/test/cypress.env.json
wc -l packages/builder/cypress.env.json
- run: yarn test:e2e:ci
env:
CI: true
name: Budibase CI
# TODO: upload recordings to s3
# - 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
# TODO look at cypress reporters
# - name: Discord Webhook Action
# uses: tsickert/discord-webhook@v4.0.0
# with:
# webhook-url: ${{ secrets.PROD_DEPLOY_WEBHOOK_URL }}
# content: "Production Deployment Complete: ${{ env.RELEASE_VERSION }} deployed to Budibase Cloud."
# embed-title: ${{ env.RELEASE_VERSION }}

View File

@ -201,9 +201,6 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center"><a href="https://github.com/seoulaja"><img src="https://avatars.githubusercontent.com/u/15101654?v=4?s=100" width="100px;" alt=""/><br /><sub><b>seoulaja</b></sub></a><br /><a href="#translation-seoulaja" title="Translation">🌍</a></td> <td align="center"><a href="https://github.com/seoulaja"><img src="https://avatars.githubusercontent.com/u/15101654?v=4?s=100" width="100px;" alt=""/><br /><sub><b>seoulaja</b></sub></a><br /><a href="#translation-seoulaja" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/mslourens"><img src="https://avatars.githubusercontent.com/u/1907152?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Maurits Lourens</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=mslourens" title="Tests">⚠️</a> <a href="https://github.com/Budibase/budibase/commits?author=mslourens" title="Code">💻</a></td> <td align="center"><a href="https://github.com/mslourens"><img src="https://avatars.githubusercontent.com/u/1907152?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Maurits Lourens</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=mslourens" title="Tests">⚠️</a> <a href="https://github.com/Budibase/budibase/commits?author=mslourens" title="Code">💻</a></td>
</tr> </tr>
<tr>
<td align="center"><a href="https://github.com/Rory-Powell"><img src="https://avatars.githubusercontent.com/u/8755148?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Rory Powell</b></sub></a><br /><a href="#infra-Rory-Powell" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/Budibase/budibase/commits?author=Rory-Powell" title="Tests">⚠️</a> <a href="https://github.com/Budibase/budibase/commits?author=Rory-Powell" title="Code">💻</a></td>
</tr>
</table> </table>
<!-- markdownlint-restore --> <!-- markdownlint-restore -->

View File

@ -99,13 +99,17 @@ spec:
- name: PLATFORM_URL - name: PLATFORM_URL
value: {{ .Values.globals.platformUrl | quote }} value: {{ .Values.globals.platformUrl | quote }}
- name: USE_QUOTAS - name: USE_QUOTAS
value: "1" value: {{ .Values.globals.useQuotas | quote }}
- name: EXCLUDE_QUOTAS_TENANTS
value: {{ .Values.globals.excludeQuotasTenants | quote }}
- name: ACCOUNT_PORTAL_URL - name: ACCOUNT_PORTAL_URL
value: {{ .Values.globals.accountPortalUrl | quote }} value: {{ .Values.globals.accountPortalUrl | quote }}
- name: ACCOUNT_PORTAL_API_KEY - name: ACCOUNT_PORTAL_API_KEY
value: {{ .Values.globals.accountPortalApiKey | quote }} value: {{ .Values.globals.accountPortalApiKey | quote }}
- name: COOKIE_DOMAIN - name: COOKIE_DOMAIN
value: {{ .Values.globals.cookieDomain | quote }} value: {{ .Values.globals.cookieDomain | quote }}
- name: HTTP_MIGRATIONS
value: {{ .Values.globals.httpMigrations | quote }}
image: budibase/apps:{{ .Values.globals.appVersion }} image: budibase/apps:{{ .Values.globals.appVersion }}
imagePullPolicy: Always imagePullPolicy: Always
name: bbapps name: bbapps

View File

@ -93,10 +93,13 @@ globals:
logLevel: info logLevel: info
selfHosted: "1" # set to 0 for budibase cloud environment, set to 1 for self-hosted setup selfHosted: "1" # set to 0 for budibase cloud environment, set to 1 for self-hosted setup
multiTenancy: "0" # set to 0 to disable multiple orgs, set to 1 to enable multiple orgs multiTenancy: "0" # set to 0 to disable multiple orgs, set to 1 to enable multiple orgs
useQuotas: "0"
excludeQuotasTenants: "" # comma seperated list of tenants to exclude from quotas
accountPortalUrl: "" accountPortalUrl: ""
accountPortalApiKey: "" accountPortalApiKey: ""
cookieDomain: "" cookieDomain: ""
platformUrl: "" platformUrl: ""
httpMigrations: "0"
createSecrets: true # creates an internal API key, JWT secrets and redis password for you createSecrets: true # creates an internal API key, JWT secrets and redis password for you
@ -239,7 +242,8 @@ couchdb:
hosts: hosts:
- chart-example.local - chart-example.local
path: / path: /
annotations: [] annotations:
[]
# kubernetes.io/ingress.class: nginx # kubernetes.io/ingress.class: nginx
# kubernetes.io/tls-acme: "true" # kubernetes.io/tls-acme: "true"
tls: tls:

View File

@ -1,5 +1,5 @@
{ {
"version": "1.0.43", "version": "1.0.49-alpha.5",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*" "packages/*"

View File

@ -36,10 +36,10 @@
"dev:server": "lerna run --parallel dev:builder --concurrency 1 --scope @budibase/worker --scope @budibase/server", "dev:server": "lerna run --parallel dev:builder --concurrency 1 --scope @budibase/worker --scope @budibase/server",
"test": "lerna run test", "test": "lerna run test",
"lint:eslint": "eslint packages", "lint:eslint": "eslint packages",
"lint:prettier": "prettier --check \"packages/**/*.{js,svelte}\"", "lint:prettier": "prettier --check \"packages/**/*.{js,ts,svelte}\"",
"lint": "yarn run lint:eslint && yarn run lint:prettier", "lint": "yarn run lint:eslint && yarn run lint:prettier",
"lint:fix:eslint": "eslint --fix packages", "lint:fix:eslint": "eslint --fix packages",
"lint:fix:prettier": "prettier --write \"packages/**/*.{js,svelte}\"", "lint:fix:prettier": "prettier --write \"packages/**/*.{js,ts,svelte}\"",
"lint:fix:ts": "lerna run lint:fix", "lint:fix:ts": "lerna run lint:fix",
"lint:fix": "yarn run lint:fix:ts && yarn run lint:fix:prettier && yarn run lint:fix:eslint", "lint:fix": "yarn run lint:fix:ts && yarn run lint:fix:prettier && yarn run lint:fix:eslint",
"test:e2e": "lerna run cy:test", "test:e2e": "lerna run cy:test",

View File

@ -1,4 +1,5 @@
module.exports = { module.exports = {
...require("./src/db/utils"), ...require("./src/db/utils"),
...require("./src/db/constants"), ...require("./src/db/constants"),
...require("./src/db/views"),
} }

View File

@ -0,0 +1 @@
module.exports = require("./src/migrations")

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/backend-core", "name": "@budibase/backend-core",
"version": "1.0.43", "version": "1.0.49-alpha.5",
"description": "Budibase backend core libraries used in server and worker", "description": "Budibase backend core libraries used in server and worker",
"main": "src/index.js", "main": "src/index.js",
"author": "Budibase", "author": "Budibase",

View File

@ -12,6 +12,8 @@ const {
tenancy, tenancy,
appTenancy, appTenancy,
authError, authError,
csrf,
internalApi,
} = require("./middleware") } = require("./middleware")
// Strategies // Strategies
@ -42,4 +44,6 @@ module.exports = {
buildAppTenancyMiddleware: appTenancy, buildAppTenancyMiddleware: appTenancy,
auditLog, auditLog,
authError, authError,
buildCsrfMiddleware: csrf,
internalApi,
} }

View File

@ -7,6 +7,7 @@ exports.Cookies = {
CurrentApp: "budibase:currentapp", CurrentApp: "budibase:currentapp",
Auth: "budibase:auth", Auth: "budibase:auth",
Init: "budibase:init", Init: "budibase:init",
DatasourceAuth: "budibase:datasourceauth",
OIDC_CONFIG: "budibase:oidc:config", OIDC_CONFIG: "budibase:oidc:config",
} }
@ -17,6 +18,7 @@ exports.Headers = {
TYPE: "x-budibase-type", TYPE: "x-budibase-type",
TENANT_ID: "x-budibase-tenant-id", TENANT_ID: "x-budibase-tenant-id",
TOKEN: "x-budibase-token", TOKEN: "x-budibase-token",
CSRF_TOKEN: "x-csrf-token",
} }
exports.GlobalRoles = { exports.GlobalRoles = {

View File

@ -21,6 +21,7 @@ exports.StaticDatabases = {
name: "global-db", name: "global-db",
docs: { docs: {
apiKeys: "apikeys", apiKeys: "apikeys",
usageQuota: "usage_quota",
}, },
}, },
// contains information about tenancy and so on // contains information about tenancy and so on
@ -28,7 +29,6 @@ exports.StaticDatabases = {
name: "global-info", name: "global-info",
docs: { docs: {
tenants: "tenants", tenants: "tenants",
usageQuota: "usage_quota",
}, },
}, },
} }

View File

@ -450,7 +450,7 @@ async function getScopedConfig(db, params) {
function generateNewUsageQuotaDoc() { function generateNewUsageQuotaDoc() {
return { return {
_id: StaticDatabases.PLATFORM_INFO.docs.usageQuota, _id: StaticDatabases.GLOBAL.docs.usageQuota,
quotaReset: Date.now() + 2592000000, quotaReset: Date.now() + 2592000000,
usageQuota: { usageQuota: {
automationRuns: 0, automationRuns: 0,

View File

@ -14,4 +14,5 @@ module.exports = {
cache: require("../cache"), cache: require("../cache"),
auth: require("../auth"), auth: require("../auth"),
constants: require("../constants"), constants: require("../constants"),
migrations: require("../migrations"),
} }

View File

@ -60,6 +60,7 @@ module.exports = (
} else { } else {
user = await getUser(userId, session.tenantId) user = await getUser(userId, session.tenantId)
} }
user.csrfToken = session.csrfToken
delete user.password delete user.password
authenticated = true authenticated = true
} catch (err) { } catch (err) {

View File

@ -0,0 +1,78 @@
const { Headers } = require("../constants")
const { buildMatcherRegex, matches } = require("./matchers")
/**
* GET, HEAD and OPTIONS methods are considered safe operations
*
* POST, PUT, PATCH, and DELETE methods, being state changing verbs,
* should have a CSRF token attached to the request
*/
const EXCLUDED_METHODS = ["GET", "HEAD", "OPTIONS"]
/**
* There are only three content type values that can be used in cross domain requests.
* If any other value is used, e.g. application/json, the browser will first make a OPTIONS
* request which will be protected by CORS.
*/
const INCLUDED_CONTENT_TYPES = [
"application/x-www-form-urlencoded",
"multipart/form-data",
"text/plain",
]
/**
* Validate the CSRF token generated aganst the user session.
* Compare the token with the x-csrf-token header.
*
* If the token is not found within the request or the value provided
* does not match the value within the user session, the request is rejected.
*
* CSRF protection provided using the 'Synchronizer Token Pattern'
* https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#synchronizer-token-pattern
*
*/
module.exports = (opts = { noCsrfPatterns: [] }) => {
const noCsrfOptions = buildMatcherRegex(opts.noCsrfPatterns)
return async (ctx, next) => {
// don't apply for excluded paths
const found = matches(ctx, noCsrfOptions)
if (found) {
return next()
}
// don't apply for the excluded http methods
if (EXCLUDED_METHODS.indexOf(ctx.method) !== -1) {
return next()
}
// don't apply when the content type isn't supported
let contentType = ctx.get("content-type")
? ctx.get("content-type").toLowerCase()
: ""
if (
!INCLUDED_CONTENT_TYPES.filter(type => contentType.includes(type)).length
) {
return next()
}
// don't apply csrf when the internal api key has been used
if (ctx.internal) {
return next()
}
// apply csrf when there is a token in the session (new logins)
// in future there should be a hard requirement that the token is present
const userToken = ctx.user.csrfToken
if (!userToken) {
return next()
}
// reject if no token in request or mismatch
const requestToken = ctx.get(Headers.CSRF_TOKEN)
if (!requestToken || requestToken !== userToken) {
ctx.throw(403, "Invalid CSRF token")
}
return next()
}
}

View File

@ -7,6 +7,9 @@ const authenticated = require("./authenticated")
const auditLog = require("./auditLog") const auditLog = require("./auditLog")
const tenancy = require("./tenancy") const tenancy = require("./tenancy")
const appTenancy = require("./appTenancy") const appTenancy = require("./appTenancy")
const internalApi = require("./internalApi")
const datasourceGoogle = require("./passport/datasource/google")
const csrf = require("./csrf")
module.exports = { module.exports = {
google, google,
@ -18,4 +21,9 @@ module.exports = {
tenancy, tenancy,
appTenancy, appTenancy,
authError, authError,
internalApi,
datasource: {
google: datasourceGoogle,
},
csrf,
} }

View File

@ -0,0 +1,14 @@
const env = require("../environment")
const { Headers } = require("../constants")
/**
* API Key only endpoint.
*/
module.exports = async (ctx, next) => {
const apiKey = ctx.request.headers[Headers.API_KEY]
if (apiKey !== env.INTERNAL_API_KEY) {
ctx.throw(403, "Unauthorized")
}
return next()
}

View File

@ -0,0 +1,76 @@
const { getScopedConfig } = require("../../../db/utils")
const { getGlobalDB } = require("../../../tenancy")
const google = require("../google")
const { Configs, Cookies } = require("../../../constants")
const { clearCookie, getCookie } = require("../../../utils")
const { getDB } = require("../../../db")
async function preAuth(passport, ctx, next) {
const db = getGlobalDB()
// get the relevant config
const config = await getScopedConfig(db, {
type: Configs.GOOGLE,
workspace: ctx.query.workspace,
})
const publicConfig = await getScopedConfig(db, {
type: Configs.SETTINGS,
})
let callbackUrl = `${publicConfig.platformUrl}/api/global/auth/datasource/google/callback`
const strategy = await google.strategyFactory(config, callbackUrl)
if (!ctx.query.appId || !ctx.query.datasourceId) {
ctx.throw(400, "appId and datasourceId query params not present.")
}
return passport.authenticate(strategy, {
scope: ["profile", "email", "https://www.googleapis.com/auth/spreadsheets"],
accessType: "offline",
prompt: "consent",
})(ctx, next)
}
async function postAuth(passport, ctx, next) {
const db = getGlobalDB()
const config = await getScopedConfig(db, {
type: Configs.GOOGLE,
workspace: ctx.query.workspace,
})
const publicConfig = await getScopedConfig(db, {
type: Configs.SETTINGS,
})
let callbackUrl = `${publicConfig.platformUrl}/api/global/auth/datasource/google/callback`
const strategy = await google.strategyFactory(
config,
callbackUrl,
(accessToken, refreshToken, profile, done) => {
clearCookie(ctx, Cookies.DatasourceAuth)
done(null, { accessToken, refreshToken })
}
)
const authStateCookie = getCookie(ctx, Cookies.DatasourceAuth)
return passport.authenticate(
strategy,
{ successRedirect: "/", failureRedirect: "/error" },
async (err, tokens) => {
// update the DB for the datasource with all the user info
const db = getDB(authStateCookie.appId)
const datasource = await db.get(authStateCookie.datasourceId)
if (!datasource.config) {
datasource.config = {}
}
datasource.config.auth = { type: "google", ...tokens }
await db.put(datasource)
ctx.redirect(
`/builder/app/${authStateCookie.appId}/data/datasource/${authStateCookie.datasourceId}`
)
}
)(ctx, next)
}
exports.preAuth = preAuth
exports.postAuth = postAuth

View File

@ -1,18 +1,17 @@
const { DEFAULT_TENANT_ID } = require("../constants")
const { DocumentTypes } = require("../db/constants") const { DocumentTypes } = require("../db/constants")
const { getGlobalDB } = require("../tenancy") const { getAllApps } = require("../db/utils")
const environment = require("../environment")
const {
doInTenant,
getTenantIds,
getGlobalDBName,
getTenantId,
} = require("../tenancy")
exports.MIGRATION_DBS = { exports.MIGRATION_TYPES = {
GLOBAL_DB: "GLOBAL_DB", GLOBAL: "global", // run once, recorded in global db, global db is provided as an argument
} APP: "app", // run per app, recorded in each app db, app db is provided as an argument
exports.MIGRATIONS = {
USER_EMAIL_VIEW_CASING: "user_email_view_casing",
}
const DB_LOOKUP = {
[exports.MIGRATION_DBS.GLOBAL_DB]: [
exports.MIGRATIONS.USER_EMAIL_VIEW_CASING,
],
} }
exports.getMigrationsDoc = async db => { exports.getMigrationsDoc = async db => {
@ -26,36 +25,90 @@ exports.getMigrationsDoc = async db => {
} }
} }
exports.migrateIfRequired = async (migrationDb, migrationName, migrateFn) => { const runMigration = async (CouchDB, migration, options = {}) => {
try { const tenantId = getTenantId()
let db const migrationType = migration.type
if (migrationDb === exports.MIGRATION_DBS.GLOBAL_DB) { const migrationName = migration.name
db = getGlobalDB()
} else {
throw new Error(`Unrecognised migration db [${migrationDb}]`)
}
if (!DB_LOOKUP[migrationDb].includes(migrationName)) { // get the db to store the migration in
throw new Error( let dbNames
`Unrecognised migration name [${migrationName}] for db [${migrationDb}]` if (migrationType === exports.MIGRATION_TYPES.GLOBAL) {
dbNames = [getGlobalDBName()]
} else if (migrationType === exports.MIGRATION_TYPES.APP) {
const apps = await getAllApps(CouchDB, migration.opts)
dbNames = apps.map(app => app.appId)
} else {
throw new Error(
`[Tenant: ${tenantId}] Unrecognised migration type [${migrationType}]`
)
}
// run the migration against each db
for (const dbName of dbNames) {
const db = new CouchDB(dbName)
try {
const doc = await exports.getMigrationsDoc(db)
// exit if the migration has been performed already
if (doc[migrationName]) {
if (
options.force &&
options.force[migrationType] &&
options.force[migrationType].includes(migrationName)
) {
console.log(
`[Tenant: ${tenantId}] [Migration: ${migrationName}] [DB: ${dbName}] Forcing`
)
} else {
// the migration has already been performed
continue
}
}
console.log(
`[Tenant: ${tenantId}] [Migration: ${migrationName}] [DB: ${dbName}] Running`
) )
// run the migration with tenant context
await migration.fn(db)
console.log(
`[Tenant: ${tenantId}] [Migration: ${migrationName}] [DB: ${dbName}] Complete`
)
// mark as complete
doc[migrationName] = Date.now()
await db.put(doc)
} catch (err) {
console.error(
`[Tenant: ${tenantId}] [Migration: ${migrationName}] [DB: ${dbName}] Error: `,
err
)
throw err
} }
const doc = await exports.getMigrationsDoc(db)
// exit if the migration has been performed
if (doc[migrationName]) {
return
}
console.log(`Performing migration: ${migrationName}`)
await migrateFn()
console.log(`Migration complete: ${migrationName}`)
// mark as complete
doc[migrationName] = Date.now()
await db.put(doc)
} catch (err) {
console.error(`Error performing migration: ${migrationName}: `, err)
throw err
} }
} }
exports.runMigrations = async (CouchDB, migrations, options = {}) => {
console.log("Running migrations")
let tenantIds
if (environment.MULTI_TENANCY) {
if (!options.tenantIds || !options.tenantIds.length) {
// run for all tenants
tenantIds = await getTenantIds()
}
} else {
// single tenancy
tenantIds = [DEFAULT_TENANT_ID]
}
// for all tenants
for (const tenantId of tenantIds) {
// for all migrations
for (const migration of migrations) {
// run the migration
await doInTenant(tenantId, () =>
runMigration(CouchDB, migration, options)
)
}
}
console.log("Migrations complete")
}

View File

@ -3,7 +3,7 @@
exports[`migrations should match snapshot 1`] = ` exports[`migrations should match snapshot 1`] = `
Object { Object {
"_id": "migrations", "_id": "migrations",
"_rev": "1-af6c272fe081efafecd2ea49a8fcbb40", "_rev": "1-6277abc4e3db950221768e5a2618a059",
"user_email_view_casing": 1487076708000, "test": 1487076708000,
} }
`; `;

View File

@ -1,7 +1,7 @@
require("../../tests/utilities/dbConfig") require("../../tests/utilities/dbConfig")
const { migrateIfRequired, MIGRATION_DBS, MIGRATIONS, getMigrationsDoc } = require("../index") const { runMigrations, getMigrationsDoc } = require("../index")
const database = require("../../db") const CouchDB = require("../../db").getCouch()
const { const {
StaticDatabases, StaticDatabases,
} = require("../../db/utils") } = require("../../db/utils")
@ -13,8 +13,14 @@ describe("migrations", () => {
const migrationFunction = jest.fn() const migrationFunction = jest.fn()
const MIGRATIONS = [{
type: "global",
name: "test",
fn: migrationFunction
}]
beforeEach(() => { beforeEach(() => {
db = database.getDB(StaticDatabases.GLOBAL.name) db = new CouchDB(StaticDatabases.GLOBAL.name)
}) })
afterEach(async () => { afterEach(async () => {
@ -22,39 +28,29 @@ describe("migrations", () => {
await db.destroy() await db.destroy()
}) })
const validMigration = () => { const migrate = () => {
return migrateIfRequired(MIGRATION_DBS.GLOBAL_DB, MIGRATIONS.USER_EMAIL_VIEW_CASING, migrationFunction) return runMigrations(CouchDB, MIGRATIONS)
} }
it("should run a new migration", async () => { it("should run a new migration", async () => {
await validMigration() await migrate()
expect(migrationFunction).toHaveBeenCalled() expect(migrationFunction).toHaveBeenCalled()
const doc = await getMigrationsDoc(db)
expect(doc.test).toBeDefined()
}) })
it("should match snapshot", async () => { it("should match snapshot", async () => {
await validMigration() await migrate()
const doc = await getMigrationsDoc(db) const doc = await getMigrationsDoc(db)
expect(doc).toMatchSnapshot() expect(doc).toMatchSnapshot()
}) })
it("should skip a previously run migration", async () => { it("should skip a previously run migration", async () => {
await validMigration() await migrate()
await validMigration() const previousMigrationTime = await getMigrationsDoc(db).test
await migrate()
const currentMigrationTime = await getMigrationsDoc(db).test
expect(migrationFunction).toHaveBeenCalledTimes(1) expect(migrationFunction).toHaveBeenCalledTimes(1)
expect(currentMigrationTime).toBe(previousMigrationTime)
}) })
it("should reject an unknown migration name", async () => {
expect(async () => {
await migrateIfRequired(MIGRATION_DBS.GLOBAL_DB, "bogus_name", migrationFunction)
}).rejects.toThrow()
expect(migrationFunction).not.toHaveBeenCalled()
})
it("should reject an unknown database name", async () => {
expect(async () => {
await migrateIfRequired("bogus_db", MIGRATIONS.USER_EMAIL_VIEW_CASING, migrationFunction)
}).rejects.toThrow()
expect(migrationFunction).not.toHaveBeenCalled()
})
}) })

View File

@ -1,4 +1,5 @@
const redis = require("../redis/authRedis") const redis = require("../redis/authRedis")
const { v4: uuidv4 } = require("uuid")
// a week in seconds // a week in seconds
const EXPIRY_SECONDS = 86400 * 7 const EXPIRY_SECONDS = 86400 * 7
@ -16,6 +17,9 @@ function makeSessionID(userId, sessionId) {
exports.createASession = async (userId, session) => { exports.createASession = async (userId, session) => {
const client = await redis.getSessionClient() const client = await redis.getSessionClient()
const sessionId = session.sessionId const sessionId = session.sessionId
if (!session.csrfToken) {
session.csrfToken = uuidv4()
}
session = { session = {
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
lastAccessedAt: new Date().toISOString(), lastAccessedAt: new Date().toISOString(),

View File

@ -148,3 +148,15 @@ exports.isUserInAppTenant = (appId, user = null) => {
const tenantId = exports.getTenantIDFromAppID(appId) || DEFAULT_TENANT_ID const tenantId = exports.getTenantIDFromAppID(appId) || DEFAULT_TENANT_ID
return tenantId === userTenantId return tenantId === userTenantId
} }
exports.getTenantIds = async () => {
const db = getDB(PLATFORM_INFO_DB)
let tenants
try {
tenants = await db.get(TENANT_DOC)
} catch (err) {
// if theres an error the doc doesn't exist, no tenants exist
return []
}
return (tenants && tenants.tenantIds) || []
}

View File

@ -20,9 +20,6 @@ const { hash } = require("./hashing")
const userCache = require("./cache/user") const userCache = require("./cache/user")
const env = require("./environment") const env = require("./environment")
const { getUserSessions, invalidateSessions } = require("./security/sessions") const { getUserSessions, invalidateSessions } = require("./security/sessions")
const { migrateIfRequired } = require("./migrations")
const { USER_EMAIL_VIEW_CASING } = require("./migrations").MIGRATIONS
const { GLOBAL_DB } = require("./migrations").MIGRATION_DBS
const APP_PREFIX = DocumentTypes.APP + SEPARATOR const APP_PREFIX = DocumentTypes.APP + SEPARATOR
@ -144,11 +141,6 @@ exports.getGlobalUserByEmail = async email => {
} }
const db = getGlobalDB() const db = getGlobalDB()
await migrateIfRequired(GLOBAL_DB, USER_EMAIL_VIEW_CASING, async () => {
// re-create the view with latest changes
await createUserEmailView(db)
})
try { try {
let users = ( let users = (
await db.query(`database/${ViewNames.USER_BY_EMAIL}`, { await db.query(`database/${ViewNames.USER_BY_EMAIL}`, {

View File

@ -3410,9 +3410,9 @@ node-fetch@2.6.0:
integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA== integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==
node-fetch@^2.6.1: node-fetch@^2.6.1:
version "2.6.6" version "2.6.7"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.6.tgz#1751a7c01834e8e1697758732e9efb6eeadfaf89" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad"
integrity sha512-Z8/6vRlTUChSdIgMa51jxQ4lrw/Jy5SOW10ObaA47/RElsAN2c5Pn8bTgFGWn/ibwzXTE8qwr1Yzx28vsecXEA== integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==
dependencies: dependencies:
whatwg-url "^5.0.0" whatwg-url "^5.0.0"

View File

@ -1,7 +1,7 @@
{ {
"name": "@budibase/bbui", "name": "@budibase/bbui",
"description": "A UI solution used in the different Budibase projects.", "description": "A UI solution used in the different Budibase projects.",
"version": "1.0.43", "version": "1.0.49-alpha.5",
"license": "MPL-2.0", "license": "MPL-2.0",
"svelte": "src/index.js", "svelte": "src/index.js",
"module": "dist/bbui.es.js", "module": "dist/bbui.es.js",

View File

@ -22,6 +22,7 @@
export let error = null export let error = null
export let fileTags = [] export let fileTags = []
export let maximum = null export let maximum = null
export let extensions = "*"
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const imageExtensions = [ const imageExtensions = [
@ -146,7 +147,9 @@
<img alt="preview" src={selectedUrl} /> <img alt="preview" src={selectedUrl} />
{:else} {:else}
<div class="placeholder"> <div class="placeholder">
<div class="extension">{selectedImage.extension}</div> <div class="extension">
{selectedImage.name || "Unknown file"}
</div>
<div>Preview not supported</div> <div>Preview not supported</div>
</div> </div>
{/if} {/if}
@ -207,6 +210,7 @@
{disabled} {disabled}
type="file" type="file"
multiple multiple
accept={extensions}
on:change={handleFile} on:change={handleFile}
/> />
<svg <svg
@ -357,18 +361,21 @@
white-space: nowrap; white-space: nowrap;
width: 0; width: 0;
margin-right: 10px; margin-right: 10px;
user-select: all;
} }
.placeholder { .placeholder {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
text-align: center;
} }
.extension { .extension {
color: var(--spectrum-global-color-gray-600); color: var(--spectrum-global-color-gray-600);
text-transform: uppercase; text-transform: uppercase;
font-weight: 600; font-weight: 600;
margin-bottom: 5px; margin-bottom: 5px;
user-select: all;
} }
.nav { .nav {

View File

@ -6,11 +6,12 @@
export let label = null export let label = null
export let labelPosition = "above" export let labelPosition = "above"
export let error = null export let error = null
export let tooltip = ""
</script> </script>
<div class="spectrum-Form-item" class:above={labelPosition === "above"}> <div class="spectrum-Form-item" class:above={labelPosition === "above"}>
{#if label} {#if label}
<FieldLabel forId={id} {label} position={labelPosition} /> <FieldLabel forId={id} {label} position={labelPosition} {tooltip} />
{/if} {/if}
<div class="spectrum-Form-itemField"> <div class="spectrum-Form-itemField">
<slot /> <slot />

View File

@ -1,19 +1,24 @@
<script> <script>
import TooltipWrapper from "../Tooltip/TooltipWrapper.svelte"
import "@spectrum-css/fieldlabel/dist/index-vars.css" import "@spectrum-css/fieldlabel/dist/index-vars.css"
export let forId export let forId
export let label export let label
export let position = "above" export let position = "above"
export let tooltip = ""
$: className = position === "above" ? "" : `spectrum-FieldLabel--${position}` $: className = position === "above" ? "" : `spectrum-FieldLabel--${position}`
</script> </script>
<label <TooltipWrapper {tooltip} size="S">
for={forId} <label
class={`spectrum-FieldLabel spectrum-FieldLabel--sizeM spectrum-Form-itemLabel ${className}`} for={forId}
> class={`spectrum-FieldLabel spectrum-FieldLabel--sizeM spectrum-Form-itemLabel ${className}`}
{label || ""} >
</label> {label || ""}
</label>
</TooltipWrapper>
<style> <style>
label { label {

View File

@ -17,6 +17,7 @@
export let quiet = false export let quiet = false
export let autoWidth = false export let autoWidth = false
export let sort = false export let sort = false
export let tooltip = ""
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = e => { const onChange = e => {
@ -32,7 +33,7 @@
} }
</script> </script>
<Field {label} {labelPosition} {error}> <Field {label} {labelPosition} {error} {tooltip}>
<Select <Select
{quiet} {quiet}
{error} {error}

View File

@ -1,73 +1,20 @@
<script> <script>
import "@spectrum-css/fieldlabel/dist/index-vars.css" import "@spectrum-css/fieldlabel/dist/index-vars.css"
import Tooltip from "../Tooltip/Tooltip.svelte" import TooltipWrapper from "../Tooltip/TooltipWrapper.svelte"
import Icon from "../Icon/Icon.svelte"
export let size = "M" export let size = "M"
export let tooltip = "" export let tooltip = ""
export let showTooltip = false
</script> </script>
{#if tooltip} <TooltipWrapper {tooltip} {size}>
<div class="container">
<label
for=""
class={`spectrum-FieldLabel spectrum-FieldLabel--size${size}`}
>
<slot />
</label>
<div class="icon-container">
<div
class="icon"
class:icon-small={size === "M" || size === "S"}
on:mouseover={() => (showTooltip = true)}
on:mouseleave={() => (showTooltip = false)}
>
<Icon name="InfoOutline" size="S" disabled={true} />
</div>
{#if showTooltip}
<div class="tooltip">
<Tooltip textWrapping={true} direction={"bottom"} text={tooltip} />
</div>
{/if}
</div>
</div>
{:else}
<label for="" class={`spectrum-FieldLabel spectrum-FieldLabel--size${size}`}> <label for="" class={`spectrum-FieldLabel spectrum-FieldLabel--size${size}`}>
<slot /> <slot />
</label> </label>
{/if} </TooltipWrapper>
<style> <style>
label { label {
padding: 0; padding: 0;
white-space: nowrap; white-space: nowrap;
} }
.container {
display: flex;
align-items: center;
}
.icon-container {
position: relative;
display: flex;
justify-content: center;
margin-top: 1px;
margin-left: 5px;
margin-right: 5px;
}
.tooltip {
position: absolute;
display: flex;
justify-content: center;
top: 15px;
z-index: 1;
width: 160px;
}
.icon {
transform: scale(0.75);
}
.icon-small {
margin-top: -2px;
margin-bottom: -5px;
}
</style> </style>

View File

@ -0,0 +1,60 @@
<script>
import Tooltip from "./Tooltip.svelte"
import Icon from "../Icon/Icon.svelte"
export let tooltip = ""
export let size = "M"
let showTooltip = false
</script>
<div class:container={!!tooltip}>
<slot />
{#if tooltip}
<div class="icon-container">
<div
class="icon"
class:icon-small={size === "M" || size === "S"}
on:mouseover={() => (showTooltip = true)}
on:mouseleave={() => (showTooltip = false)}
>
<Icon name="InfoOutline" size="S" disabled={true} />
</div>
{#if showTooltip}
<div class="tooltip">
<Tooltip textWrapping={true} direction={"bottom"} text={tooltip} />
</div>
{/if}
</div>
{/if}
</div>
<style>
.container {
display: flex;
align-items: center;
}
.icon-container {
position: relative;
display: flex;
justify-content: center;
margin-top: 1px;
margin-left: 5px;
margin-right: 5px;
}
.tooltip {
position: absolute;
display: flex;
justify-content: center;
top: 15px;
z-index: 1;
width: 160px;
}
.icon {
transform: scale(0.75);
}
.icon-small {
margin-top: -2px;
margin-bottom: -5px;
}
</style>

View File

@ -40,7 +40,7 @@ async function run() {
// it will cause environment module to be loaded prematurely // it will cause environment module to be loaded prematurely
const server = require("../../server/dist/app") const server = require("../../server/dist/app")
process.env.PORT = WORKER_PORT process.env.PORT = WORKER_PORT
const worker = require("../../worker/src/index") const worker = require("../../worker/dist/index")
// reload main port for rest of system // reload main port for rest of system
process.env.PORT = MAIN_PORT process.env.PORT = MAIN_PORT
server.on("close", () => console.log("Server Closed")) server.on("close", () => console.log("Server Closed"))

View File

@ -35,7 +35,13 @@ Cypress.Commands.add("login", () => {
Cypress.Commands.add("createApp", name => { Cypress.Commands.add("createApp", name => {
cy.visit(`localhost:${Cypress.env("PORT")}/builder`) cy.visit(`localhost:${Cypress.env("PORT")}/builder`)
cy.wait(500) cy.wait(500)
cy.contains(/Start from scratch/).dblclick() cy.request(`${Cypress.config().baseUrl}api/applications?status=all`)
.its("body")
.then(body => {
if (body.length > 0) {
cy.get(".spectrum-Button").contains("Create app").click({ force: true })
}
})
cy.get(".spectrum-Modal").within(() => { cy.get(".spectrum-Modal").within(() => {
cy.get("input").eq(0).type(name).should("have.value", name).blur() cy.get("input").eq(0).type(name).should("have.value", name).blur()
cy.get(".spectrum-ButtonGroup").contains("Create app").click() cy.get(".spectrum-ButtonGroup").contains("Create app").click()

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/builder", "name": "@budibase/builder",
"version": "1.0.43", "version": "1.0.49-alpha.5",
"license": "GPL-3.0", "license": "GPL-3.0",
"private": true, "private": true,
"scripts": { "scripts": {
@ -65,10 +65,10 @@
} }
}, },
"dependencies": { "dependencies": {
"@budibase/bbui": "^1.0.43", "@budibase/bbui": "^1.0.49-alpha.5",
"@budibase/client": "^1.0.43", "@budibase/client": "^1.0.49-alpha.5",
"@budibase/colorpicker": "1.1.2", "@budibase/colorpicker": "1.1.2",
"@budibase/string-templates": "^1.0.43", "@budibase/string-templates": "^1.0.49-alpha.5",
"@sentry/browser": "5.19.1", "@sentry/browser": "5.19.1",
"@spectrum-css/page": "^3.0.1", "@spectrum-css/page": "^3.0.1",
"@spectrum-css/vars": "^3.0.1", "@spectrum-css/vars": "^3.0.1",

View File

@ -1,11 +1,20 @@
import { store } from "./index" import { store } from "./index"
import { get as svelteGet } from "svelte/store" import { get as svelteGet } from "svelte/store"
import { removeCookie, Cookies } from "./cookies" import { removeCookie, Cookies } from "./cookies"
import { auth } from "stores/portal"
const apiCall = const apiCall =
method => method =>
async (url, body, headers = { "Content-Type": "application/json" }) => { async (url, body, headers = { "Content-Type": "application/json" }) => {
headers["x-budibase-app-id"] = svelteGet(store).appId headers["x-budibase-app-id"] = svelteGet(store).appId
headers["x-budibase-api-version"] = "1"
// add csrf token if authenticated
const user = svelteGet(auth).user
if (user && user.csrfToken) {
headers["x-csrf-token"] = user.csrfToken
}
const json = headers["Content-Type"] === "application/json" const json = headers["Content-Type"] === "application/json"
const resp = await fetch(url, { const resp = await fetch(url, {
method: method, method: method,

View File

@ -1,16 +1,26 @@
export const Cookies = { export const Cookies = {
Auth: "budibase:auth", Auth: "budibase:auth",
CurrentApp: "budibase:currentapp", CurrentApp: "budibase:currentapp",
ReturnUrl: "budibase:returnurl",
}
export function setCookie(name, value) {
if (getCookie(name)) {
removeCookie(name)
}
window.document.cookie = `${name}=${value}; Path=/;`
} }
export function getCookie(cookieName) { export function getCookie(cookieName) {
return document.cookie.split(";").some(cookie => { const value = `; ${document.cookie}`
return cookie.trim().startsWith(`${cookieName}=`) const parts = value.split(`; ${cookieName}=`)
}) if (parts.length === 2) {
return parts[1].split(";").shift()
}
} }
export function removeCookie(cookieName) { export function removeCookie(cookieName) {
if (getCookie(cookieName)) { if (getCookie(cookieName)) {
document.cookie = `${cookieName}=; Max-Age=-99999999;` document.cookie = `${cookieName}=; Max-Age=-99999999; Path=/;`
} }
} }

View File

@ -1,6 +1,5 @@
import { getFrontendStore } from "./store/frontend" import { getFrontendStore } from "./store/frontend"
import { getAutomationStore } from "./store/automation" import { getAutomationStore } from "./store/automation"
import { getHostingStore } from "./store/hosting"
import { getThemeStore } from "./store/theme" import { getThemeStore } from "./store/theme"
import { derived, writable } from "svelte/store" import { derived, writable } from "svelte/store"
import { FrontendTypes, LAYOUT_NAMES } from "../constants" import { FrontendTypes, LAYOUT_NAMES } from "../constants"
@ -9,7 +8,6 @@ import { findComponent } from "./componentUtils"
export const store = getFrontendStore() export const store = getFrontendStore()
export const automationStore = getAutomationStore() export const automationStore = getAutomationStore()
export const themeStore = getThemeStore() export const themeStore = getThemeStore()
export const hostingStore = getHostingStore()
export const currentAsset = derived(store, $store => { export const currentAsset = derived(store, $store => {
const type = $store.currentFrontEndType const type = $store.currentFrontEndType

View File

@ -2,7 +2,6 @@ import { get, writable } from "svelte/store"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { import {
allScreens, allScreens,
hostingStore,
currentAsset, currentAsset,
mainLayout, mainLayout,
selectedComponent, selectedComponent,
@ -100,7 +99,6 @@ export const getFrontendStore = () => {
version: application.version, version: application.version,
revertableVersion: application.revertableVersion, revertableVersion: application.revertableVersion,
})) }))
await hostingStore.actions.fetch()
// Initialise backend stores // Initialise backend stores
const [_integrations] = await Promise.all([ const [_integrations] = await Promise.all([

View File

@ -1,34 +0,0 @@
import { writable } from "svelte/store"
import api, { get } from "../api"
const INITIAL_HOSTING_UI_STATE = {
appUrl: "",
deployedApps: {},
deployedAppNames: [],
deployedAppUrls: [],
}
export const getHostingStore = () => {
const store = writable({ ...INITIAL_HOSTING_UI_STATE })
store.actions = {
fetch: async () => {
const response = await api.get("/api/hosting/urls")
const urls = await response.json()
store.update(state => {
state.appUrl = urls.app
return state
})
},
fetchDeployedApps: async () => {
let deployments = await (await get("/api/hosting/apps")).json()
store.update(state => {
state.deployedApps = deployments
state.deployedAppNames = Object.values(deployments).map(app => app.name)
state.deployedAppUrls = Object.values(deployments).map(app => app.url)
return state
})
return deployments
},
}
return store
}

View File

@ -22,8 +22,10 @@
RelationshipTypes, RelationshipTypes,
ALLOWABLE_STRING_OPTIONS, ALLOWABLE_STRING_OPTIONS,
ALLOWABLE_NUMBER_OPTIONS, ALLOWABLE_NUMBER_OPTIONS,
ALLOWABLE_JSON_OPTIONS,
ALLOWABLE_STRING_TYPES, ALLOWABLE_STRING_TYPES,
ALLOWABLE_NUMBER_TYPES, ALLOWABLE_NUMBER_TYPES,
ALLOWABLE_JSON_TYPES,
SWITCHABLE_TYPES, SWITCHABLE_TYPES,
} from "constants/backend" } from "constants/backend"
import { getAutoColumnInformation, buildAutoColumn } from "builderStore/utils" import { getAutoColumnInformation, buildAutoColumn } from "builderStore/utils"
@ -126,7 +128,12 @@
} }
} }
function cancelEdit() {
field.name = originalName
}
function deleteColumn() { function deleteColumn() {
field.name = deleteColName
if (field.name === $tables.selected.primaryDisplay) { if (field.name === $tables.selected.primaryDisplay) {
notifications.error("You cannot delete the display column") notifications.error("You cannot delete the display column")
} else { } else {
@ -145,6 +152,7 @@
delete field.subtype delete field.subtype
delete field.tableId delete field.tableId
delete field.relationshipType delete field.relationshipType
delete field.formulaType
// Add in defaults and initial definition // Add in defaults and initial definition
const definition = fieldDefinitions[event.detail?.toUpperCase()] const definition = fieldDefinitions[event.detail?.toUpperCase()]
@ -156,6 +164,9 @@
if (field.type === LINK_TYPE) { if (field.type === LINK_TYPE) {
field.relationshipType = RelationshipTypes.MANY_TO_MANY field.relationshipType = RelationshipTypes.MANY_TO_MANY
} }
if (field.type === FORMULA_TYPE) {
field.formulaType = "dynamic"
}
} }
function onChangeRequired(e) { function onChangeRequired(e) {
@ -236,6 +247,11 @@
ALLOWABLE_NUMBER_TYPES.indexOf(field.type) !== -1 ALLOWABLE_NUMBER_TYPES.indexOf(field.type) !== -1
) { ) {
return ALLOWABLE_NUMBER_OPTIONS return ALLOWABLE_NUMBER_OPTIONS
} else if (
originalName &&
ALLOWABLE_JSON_TYPES.indexOf(field.type) !== -1
) {
return ALLOWABLE_JSON_OPTIONS
} else if (!external) { } else if (!external) {
return [ return [
...Object.values(fieldDefinitions), ...Object.values(fieldDefinitions),
@ -307,6 +323,7 @@
title={originalName ? "Edit Column" : "Create Column"} title={originalName ? "Edit Column" : "Create Column"}
confirmText="Save Column" confirmText="Save Column"
onConfirm={saveColumn} onConfirm={saveColumn}
onCancel={cancelEdit}
disabled={invalid} disabled={invalid}
> >
<Input <Input
@ -425,8 +442,22 @@
error={errors.relatedName} error={errors.relatedName}
/> />
{:else if field.type === FORMULA_TYPE} {:else if field.type === FORMULA_TYPE}
{#if !table.sql}
<Select
label="Formula type"
bind:value={field.formulaType}
options={[
{ label: "Dynamic", value: "dynamic" },
{ label: "Static", value: "static" },
]}
getOptionLabel={option => option.label}
getOptionValue={option => option.value}
tooltip="Dynamic formula are calculated when retrieved, but cannot be filtered,
while static formula are calculated when the row is saved."
/>
{/if}
<ModalBindableInput <ModalBindableInput
title="Handlebars Formula" title="Formula"
label="Formula" label="Formula"
value={field.formula} value={field.formula}
on:change={e => (field.formula = e.detail)} on:change={e => (field.formula = e.detail)}
@ -435,7 +466,7 @@
/> />
{:else if field.type === AUTO_TYPE} {:else if field.type === AUTO_TYPE}
<Select <Select
label="Auto Column Type" label="Auto column type"
value={field.subtype} value={field.subtype}
on:change={e => (field.subtype = e.detail)} on:change={e => (field.subtype = e.detail)}
options={Object.entries(getAutoColumnInformation())} options={Object.entries(getAutoColumnInformation())}
@ -470,16 +501,16 @@
onOk={deleteColumn} onOk={deleteColumn}
onCancel={hideDeleteDialog} onCancel={hideDeleteDialog}
title="Confirm Deletion" title="Confirm Deletion"
disabled={deleteColName !== field.name} disabled={deleteColName !== originalName}
> >
<p> <p>
Are you sure you wish to delete the column <b>{field.name}?</b> Are you sure you wish to delete the column <b>{originalName}?</b>
Your data will be deleted and this action cannot be undone - enter the column Your data will be deleted and this action cannot be undone - enter the column
name to confirm. name to confirm.
</p> </p>
<Input <Input
dataCy="delete-column-confirm" dataCy="delete-column-confirm"
bind:value={deleteColName} bind:value={deleteColName}
placeholder={field.name} placeholder={originalName}
/> />
</ConfirmDialog> </ConfirmDialog>

View File

@ -9,6 +9,7 @@
Select, Select,
Body, Body,
Layout, Layout,
ActionButton,
} from "@budibase/bbui" } from "@budibase/bbui"
import { onMount, createEventDispatcher } from "svelte" import { onMount, createEventDispatcher } from "svelte"
import { FIELDS } from "constants/backend" import { FIELDS } from "constants/backend"
@ -20,8 +21,8 @@
let dispatcher = createEventDispatcher() let dispatcher = createEventDispatcher()
let mode = "Form" let mode = "Form"
let fieldCount = 0 let fieldCount = 0
let fieldKeys = {}, let fieldKeys = [],
fieldTypes = {} fieldTypes = []
let keyValueOptions = [ let keyValueOptions = [
{ label: "String", value: FIELDS.STRING.type }, { label: "String", value: FIELDS.STRING.type },
{ label: "Number", value: FIELDS.NUMBER.type }, { label: "Number", value: FIELDS.NUMBER.type },
@ -50,27 +51,48 @@
if (!schema) { if (!schema) {
schema = {} schema = {}
} }
let i = 0 // find the entries which aren't in the list
for (let [key, value] of Object.entries(schema)) { const schemaEntries = Object.entries(schema).filter(
fieldKeys[i] = key ([key]) => !fieldKeys.includes(key)
fieldTypes[i] = value.type )
i++ for (let [key, value] of schemaEntries) {
fieldKeys.push(key)
fieldTypes.push(value.type)
} }
fieldCount = i fieldCount = fieldKeys.length
} }
function saveSchema() { function saveSchema() {
for (let i of Object.keys(fieldKeys)) { const newSchema = {}
const key = fieldKeys[i] for (let [index, key] of fieldKeys.entries()) {
// they were added to schema, rather than generated // they were added to schema, rather than generated
if (!schema[key]) { newSchema[key] = {
schema[key] = { ...schema[key],
type: fieldTypes[i], type: fieldTypes[index],
}
} }
} }
dispatcher("save", { schema: newSchema, json })
schema = newSchema
}
dispatcher("save", { schema, json }) function removeKey(index) {
const keyToRemove = fieldKeys[index]
if (fieldKeys[index + 1] != null) {
fieldKeys[index] = fieldKeys[index + 1]
fieldTypes[index] = fieldTypes[index + 1]
}
fieldKeys.splice(index, 1)
fieldTypes.splice(index, 1)
fieldCount--
if (json) {
try {
const parsed = JSON.parse(json)
delete parsed[keyToRemove]
json = JSON.stringify(parsed, null, 2)
} catch (err) {
// json not valid, ignore
}
}
} }
onMount(() => { onMount(() => {
@ -97,6 +119,7 @@
getOptionValue={field => field.value} getOptionValue={field => field.value}
getOptionLabel={field => field.label} getOptionLabel={field => field.label}
/> />
<ActionButton icon="Close" quiet on:click={() => removeKey(i)} />
</div> </div>
{/each} {/each}
<div class:add-field-btn={fieldCount !== 0}> <div class:add-field-btn={fieldCount !== 0}>
@ -118,9 +141,9 @@
<style> <style>
.horizontal { .horizontal {
display: grid; display: grid;
grid-template-columns: 30% 1fr; grid-template-columns: 30% 1fr 40px;
grid-gap: var(--spacing-s); grid-gap: var(--spacing-s);
align-items: center; align-items: end;
} }
.add-field-btn { .add-field-btn {

View File

@ -6,9 +6,12 @@
export let datasource export let datasource
let name = "" let name = ""
let submitted = false
$: valid = name && name.length > 0 && !datasource?.entities[name] $: valid = name && name.length > 0 && !datasource?.entities[name]
$: error = $: error =
name && datasource?.entities[name] ? "Table name already in use." : null !submitted && name && datasource?.entities[name]
? "Table name already in use."
: null
function buildDefaultTable(tableName, datasourceId) { function buildDefaultTable(tableName, datasourceId) {
return { return {
@ -26,6 +29,7 @@
} }
async function saveTable() { async function saveTable() {
submitted = true
const table = await tables.save(buildDefaultTable(name, datasource._id)) const table = await tables.save(buildDefaultTable(name, datasource._id))
await datasources.fetch() await datasources.fetch()
$goto(`../../table/${table._id}`) $goto(`../../table/${table._id}`)

View File

@ -188,7 +188,7 @@
{:else} {:else}
<Body size="S"><i>No tables found.</i></Body> <Body size="S"><i>No tables found.</i></Body>
{/if} {/if}
{#if plusTables?.length !== 0} {#if plusTables?.length !== 0 && integration.relationships}
<Divider size="S" /> <Divider size="S" />
<div class="query-header"> <div class="query-header">
<Heading size="S">Relationships</Heading> <Heading size="S">Relationships</Heading>

View File

@ -0,0 +1,47 @@
<script>
import { ActionButton } from "@budibase/bbui"
import GoogleLogo from "assets/google-logo.png"
import { store } from "builderStore"
import { auth } from "stores/portal"
export let preAuthStep
export let datasource
$: tenantId = $auth.tenantId
</script>
<ActionButton
on:click={async () => {
let ds = datasource
if (!ds) {
ds = await preAuthStep()
}
window.open(
`/api/global/auth/${tenantId}/datasource/google?datasourceId=${datasource._id}&appId=${$store.appId}`,
"_blank"
)
}}
>
<div class="inner">
<img src={GoogleLogo} alt="google icon" />
<p>Sign in with Google</p>
</div>
</ActionButton>
<style>
.inner {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding-top: var(--spacing-xs);
padding-bottom: var(--spacing-xs);
}
.inner img {
width: 18px;
margin: 3px 10px 3px 3px;
}
.inner p {
margin: 0;
}
</style>

View File

@ -0,0 +1,184 @@
<script>
export let width = "100"
export let height = "100"
</script>
<svg
{width}
{height}
version="1.0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 50 80"
preserveAspectRatio="xMidYMid meet"
>
<defs>
<path
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
id="path-1"
/>
<path
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
id="path-3"
/>
<path
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
id="path-5"
/>
<linearGradient
x1="50.0053945%"
y1="8.58610612%"
x2="50.0053945%"
y2="100.013939%"
id="linearGradient-7"
>
<stop stop-color="#263238" stop-opacity="0.2" offset="0%" />
<stop stop-color="#263238" stop-opacity="0.02" offset="100%" />
</linearGradient>
<path
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
id="path-8"
/>
<path
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
id="path-10"
/>
<path
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
id="path-12"
/>
<path
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
id="path-14"
/>
<radialGradient
cx="3.16804688%"
cy="2.71744318%"
fx="3.16804688%"
fy="2.71744318%"
r="161.248516%"
gradientTransform="translate(0.031680,0.027174),scale(1.000000,0.727273),translate(-0.031680,-0.027174)"
id="radialGradient-16"
>
<stop stop-color="#FFFFFF" stop-opacity="0.1" offset="0%" />
<stop stop-color="#FFFFFF" stop-opacity="0" offset="100%" />
</radialGradient>
</defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g
id="Consumer-Apps-Sheets-Large-VD-R8-"
transform="translate(-451.000000, -451.000000)"
>
<g id="Hero" transform="translate(0.000000, 63.000000)">
<g id="Personal" transform="translate(277.000000, 299.000000)">
<g id="Sheets-icon" transform="translate(174.833333, 89.958333)">
<g id="Group">
<g id="Clipped">
<mask id="mask-2" fill="white">
<use xlink:href="#path-1" />
</mask>
<g id="SVGID_1_" />
<path
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L36.9791667,10.3541667 L29.5833333,0 Z"
id="Path"
fill="#0F9D58"
fill-rule="nonzero"
mask="url(#mask-2)"
/>
</g>
<g id="Clipped">
<mask id="mask-4" fill="white">
<use xlink:href="#path-3" />
</mask>
<g id="SVGID_1_" />
<path
d="M11.8333333,31.8020833 L11.8333333,53.25 L35.5,53.25 L35.5,31.8020833 L11.8333333,31.8020833 Z M22.1875,50.2916667 L14.7916667,50.2916667 L14.7916667,46.59375 L22.1875,46.59375 L22.1875,50.2916667 Z M22.1875,44.375 L14.7916667,44.375 L14.7916667,40.6770833 L22.1875,40.6770833 L22.1875,44.375 Z M22.1875,38.4583333 L14.7916667,38.4583333 L14.7916667,34.7604167 L22.1875,34.7604167 L22.1875,38.4583333 Z M32.5416667,50.2916667 L25.1458333,50.2916667 L25.1458333,46.59375 L32.5416667,46.59375 L32.5416667,50.2916667 Z M32.5416667,44.375 L25.1458333,44.375 L25.1458333,40.6770833 L32.5416667,40.6770833 L32.5416667,44.375 Z M32.5416667,38.4583333 L25.1458333,38.4583333 L25.1458333,34.7604167 L32.5416667,34.7604167 L32.5416667,38.4583333 Z"
id="Shape"
fill="#F1F1F1"
fill-rule="nonzero"
mask="url(#mask-4)"
/>
</g>
<g id="Clipped">
<mask id="mask-6" fill="white">
<use xlink:href="#path-5" />
</mask>
<g id="SVGID_1_" />
<polygon
id="Path"
fill="url(#linearGradient-7)"
fill-rule="nonzero"
mask="url(#mask-6)"
points="30.8813021 16.4520313 47.3333333 32.9003646 47.3333333 17.75"
/>
</g>
<g id="Clipped">
<mask id="mask-9" fill="white">
<use xlink:href="#path-8" />
</mask>
<g id="SVGID_1_" />
<g id="Group" mask="url(#mask-9)">
<g transform="translate(26.625000, -2.958333)">
<path
d="M2.95833333,2.95833333 L2.95833333,16.2708333 C2.95833333,18.7225521 4.94411458,20.7083333 7.39583333,20.7083333 L20.7083333,20.7083333 L2.95833333,2.95833333 Z"
id="Path"
fill="#87CEAC"
fill-rule="nonzero"
/>
</g>
</g>
</g>
<g id="Clipped">
<mask id="mask-11" fill="white">
<use xlink:href="#path-10" />
</mask>
<g id="SVGID_1_" />
<path
d="M4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,4.80729167 C0,2.36666667 1.996875,0.369791667 4.4375,0.369791667 L29.5833333,0.369791667 L29.5833333,0 L4.4375,0 Z"
id="Path"
fill-opacity="0.2"
fill="#FFFFFF"
fill-rule="nonzero"
mask="url(#mask-11)"
/>
</g>
<g id="Clipped">
<mask id="mask-13" fill="white">
<use xlink:href="#path-12" />
</mask>
<g id="SVGID_1_" />
<path
d="M42.8958333,64.7135417 L4.4375,64.7135417 C1.996875,64.7135417 0,62.7166667 0,60.2760417 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,60.2760417 C47.3333333,62.7166667 45.3364583,64.7135417 42.8958333,64.7135417 Z"
id="Path"
fill-opacity="0.2"
fill="#263238"
fill-rule="nonzero"
mask="url(#mask-13)"
/>
</g>
<g id="Clipped">
<mask id="mask-15" fill="white">
<use xlink:href="#path-14" />
</mask>
<g id="SVGID_1_" />
<path
d="M34.0208333,17.75 C31.5691146,17.75 29.5833333,15.7642188 29.5833333,13.3125 L29.5833333,13.6822917 C29.5833333,16.1340104 31.5691146,18.1197917 34.0208333,18.1197917 L47.3333333,18.1197917 L47.3333333,17.75 L34.0208333,17.75 Z"
id="Path"
fill-opacity="0.1"
fill="#263238"
fill-rule="nonzero"
mask="url(#mask-15)"
/>
</g>
</g>
<path
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z"
id="Path"
fill="url(#radialGradient-16)"
fill-rule="nonzero"
/>
</g>
</g>
</g>
</g>
</g>
</svg>

View File

@ -11,6 +11,7 @@ import ArangoDB from "./ArangoDB.svelte"
import Rest from "./Rest.svelte" import Rest from "./Rest.svelte"
import Budibase from "./Budibase.svelte" import Budibase from "./Budibase.svelte"
import Oracle from "./Oracle.svelte" import Oracle from "./Oracle.svelte"
import GoogleSheets from "./GoogleSheets.svelte"
export default { export default {
BUDIBASE: Budibase, BUDIBASE: Budibase,
@ -26,4 +27,5 @@ export default {
ARANGODB: ArangoDB, ARANGODB: ArangoDB,
REST: Rest, REST: Rest,
ORACLE: Oracle, ORACLE: Oracle,
GOOGLE_SHEETS: GoogleSheets,
} }

View File

@ -6,6 +6,7 @@
import { IntegrationNames, IntegrationTypes } from "constants/backend" import { IntegrationNames, IntegrationTypes } from "constants/backend"
import CreateTableModal from "components/backend/TableNavigator/modals/CreateTableModal.svelte" import CreateTableModal from "components/backend/TableNavigator/modals/CreateTableModal.svelte"
import DatasourceConfigModal from "components/backend/DatasourceNavigator/modals/DatasourceConfigModal.svelte" import DatasourceConfigModal from "components/backend/DatasourceNavigator/modals/DatasourceConfigModal.svelte"
import GoogleDatasourceConfigModal from "components/backend/DatasourceNavigator/modals/GoogleDatasourceConfigModal.svelte"
import { createRestDatasource } from "builderStore/datasource" import { createRestDatasource } from "builderStore/datasource"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import ImportRestQueriesModal from "./ImportRestQueriesModal.svelte" import ImportRestQueriesModal from "./ImportRestQueriesModal.svelte"
@ -38,6 +39,7 @@
plus: selected.plus, plus: selected.plus,
config, config,
schema: selected.datasource, schema: selected.datasource,
auth: selected.auth,
} }
checkShowImport() checkShowImport()
} }
@ -79,7 +81,11 @@
</Modal> </Modal>
<Modal bind:this={externalDatasourceModal}> <Modal bind:this={externalDatasourceModal}>
<DatasourceConfigModal {integration} {modal} /> {#if integration?.auth?.type === "google"}
<GoogleDatasourceConfigModal {integration} {modal} />
{:else}
<DatasourceConfigModal {integration} {modal} />
{/if}
</Modal> </Modal>
<Modal bind:this={importModal}> <Modal bind:this={importModal}>

View File

@ -51,13 +51,9 @@
>Connect your database to Budibase using the config below. >Connect your database to Budibase using the config below.
</Body> </Body>
</Layout> </Layout>
<IntegrationConfigForm <IntegrationConfigForm
schema={datasource.schema} schema={datasource.schema}
bind:datasource bind:datasource
creating={true} creating={true}
/> />
</ModalContent> </ModalContent>
<style>
</style>

View File

@ -0,0 +1,29 @@
<script>
import { ModalContent, Body, Layout } from "@budibase/bbui"
import { IntegrationNames } from "constants/backend"
import cloneDeep from "lodash/cloneDeepWith"
import GoogleButton from "../_components/GoogleButton.svelte"
import { saveDatasource as save } from "builderStore/datasource"
export let integration
export let modal
// kill the reference so the input isn't saved
let datasource = cloneDeep(integration)
</script>
<ModalContent
title={`Connect to ${IntegrationNames[datasource.type]}`}
onCancel={() => modal.show()}
cancelText="Back"
size="L"
>
<Layout noPadding>
<Body size="XS"
>Authenticate with your google account to use the {IntegrationNames[
datasource.type
]} integration.</Body
>
</Layout>
<GoogleButton preAuthStep={() => save(datasource, true)} />
</ModalContent>

View File

@ -53,16 +53,23 @@
} }
// Create table // Create table
const table = await tables.save(newTable) let table
notifications.success(`Table ${name} created successfully.`) try {
analytics.captureEvent(Events.TABLE.CREATED, { name }) table = await tables.save(newTable)
notifications.success(`Table ${name} created successfully.`)
analytics.captureEvent(Events.TABLE.CREATED, { name })
// Navigate to new table // Navigate to new table
const currentUrl = $url() const currentUrl = $url()
const path = currentUrl.endsWith("data") const path = currentUrl.endsWith("data")
? `./table/${table._id}` ? `./table/${table._id}`
: `../../table/${table._id}` : `../../table/${table._id}`
$goto(path) $goto(path)
} catch (e) {
notifications.error(e)
// reload in case the table was created
await tables.fetch()
}
} }
</script> </script>

View File

@ -6,7 +6,7 @@
import api from "builderStore/api" import api from "builderStore/api"
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
import CreateWebhookDeploymentModal from "./CreateWebhookDeploymentModal.svelte" import CreateWebhookDeploymentModal from "./CreateWebhookDeploymentModal.svelte"
import { store, hostingStore } from "builderStore" import { store } from "builderStore"
const DeploymentStatus = { const DeploymentStatus = {
SUCCESS: "SUCCESS", SUCCESS: "SUCCESS",
@ -37,7 +37,7 @@
let poll let poll
let deployments = [] let deployments = []
let urlComponent = $store.url || `/${appId}` let urlComponent = $store.url || `/${appId}`
let deploymentUrl = `${$hostingStore.appUrl}${urlComponent}` let deploymentUrl = `${urlComponent}`
const formatDate = (date, format) => const formatDate = (date, format) =>
Intl.DateTimeFormat("en-GB", DATE_OPTIONS[format]).format(date) Intl.DateTimeFormat("en-GB", DATE_OPTIONS[format]).format(date)

View File

@ -1,6 +1,7 @@
<script> <script>
import { Select } from "@budibase/bbui" import { Select } from "@budibase/bbui"
import { store } from "builderStore" import { store } from "builderStore"
import { get } from "svelte/store"
const themeOptions = [ const themeOptions = [
{ {
@ -20,6 +21,17 @@
value: "spectrum--darkest", value: "spectrum--darkest",
}, },
] ]
const onChangeTheme = async theme => {
await store.actions.theme.save(theme)
await store.actions.customTheme.save({
...get(store).customTheme,
navBackground:
theme === "spectrum--light"
? "var(--spectrum-global-color-gray-50)"
: "var(--spectrum-global-color-gray-100)",
})
}
</script> </script>
<div> <div>
@ -27,7 +39,7 @@
value={$store.theme} value={$store.theme}
options={themeOptions} options={themeOptions}
placeholder={null} placeholder={null}
on:change={e => store.actions.theme.save(e.detail)} on:change={e => onChangeTheme(e.detail)}
/> />
</div> </div>

View File

@ -19,7 +19,7 @@
primaryColor: "var(--spectrum-global-color-blue-600)", primaryColor: "var(--spectrum-global-color-blue-600)",
primaryColorHover: "var(--spectrum-global-color-blue-500)", primaryColorHover: "var(--spectrum-global-color-blue-500)",
buttonBorderRadius: "16px", buttonBorderRadius: "16px",
navBackground: "var(--spectrum-global-color-gray-100)", navBackground: "var(--spectrum-global-color-gray-50)",
navTextColor: "var(--spectrum-global-color-gray-800)", navTextColor: "var(--spectrum-global-color-gray-800)",
} }
@ -52,7 +52,14 @@
} }
const resetTheme = () => { const resetTheme = () => {
store.actions.customTheme.save(null) const theme = get(store).theme
store.actions.customTheme.save({
...defaultTheme,
navBackground:
theme === "spectrum--light"
? "var(--spectrum-global-color-gray-50)"
: "var(--spectrum-global-color-gray-100)",
})
} }
</script> </script>

View File

@ -44,7 +44,8 @@
"relationshipfield", "relationshipfield",
"daterangepicker", "daterangepicker",
"multifieldselect", "multifieldselect",
"jsonfield" "jsonfield",
"s3upload"
] ]
}, },
{ {

View File

@ -55,8 +55,8 @@
<div> <div>
<BindingBuilder <BindingBuilder
bind:customParams={parameters.queryParams} bind:customParams={parameters.queryParams}
bindings={query.parameters} queryBindings={query.parameters}
bind:bindableOptions={bindings} bind:bindings
/> />
<IntegrationQueryEditor <IntegrationQueryEditor
height={200} height={200}

View File

@ -1,13 +1,38 @@
<script> <script>
import { Body } from "@budibase/bbui" import { Label, Body, Layout } from "@budibase/bbui"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
export let parameters
export let bindings = []
</script> </script>
<div class="root"> <div class="root">
<Body size="S">This action doesn't require any additional settings.</Body> <Layout noPadding gap="M">
<Body size="S">
Please enter the URL you would like to be redirected to after logging out.
If you don't enter a value, you'll be redirected to the login screen.
</Body>
<div class="content">
<Label small>Redirect URL</Label>
<DrawerBindableInput
title="Return URL"
value={parameters.redirectUrl}
on:change={value => (parameters.redirectUrl = value.detail)}
{bindings}
/>
</div>
</Layout>
</div> </div>
<style> <style>
.root { .root {
max-width: 400px;
margin: 0 auto; margin: 0 auto;
} }
.content {
display: grid;
align-items: center;
gap: var(--spacing-m);
grid-template-columns: auto 1fr;
}
</style> </style>

View File

@ -0,0 +1,33 @@
<script>
import { Select, Label } from "@budibase/bbui"
import { currentAsset } from "builderStore"
import { findAllMatchingComponents } from "builderStore/componentUtils"
export let parameters
$: components = findAllMatchingComponents($currentAsset.props, component =>
component._component.endsWith("s3upload")
)
</script>
<div class="root">
<Label small>S3 Upload Component</Label>
<Select
bind:value={parameters.componentId}
options={components}
getOptionLabel={x => x._instanceName}
getOptionValue={x => x._id}
/>
</div>
<style>
.root {
display: grid;
column-gap: var(--spacing-l);
row-gap: var(--spacing-s);
grid-template-columns: 120px 1fr;
align-items: center;
max-width: 400px;
margin: 0 auto;
}
</style>

View File

@ -11,3 +11,4 @@ export { default as ChangeFormStep } from "./ChangeFormStep.svelte"
export { default as UpdateState } from "./UpdateState.svelte" export { default as UpdateState } from "./UpdateState.svelte"
export { default as RefreshDataProvider } from "./RefreshDataProvider.svelte" export { default as RefreshDataProvider } from "./RefreshDataProvider.svelte"
export { default as DuplicateRow } from "./DuplicateRow.svelte" export { default as DuplicateRow } from "./DuplicateRow.svelte"
export { default as S3Upload } from "./S3Upload.svelte"

View File

@ -70,6 +70,16 @@
"name": "Update State", "name": "Update State",
"component": "UpdateState", "component": "UpdateState",
"dependsOnFeature": "state" "dependsOnFeature": "state"
},
{
"name": "Upload File to S3",
"component": "S3Upload",
"context": [
{
"label": "File URL",
"value": "publicUrl"
}
]
} }
] ]
} }

View File

@ -169,8 +169,8 @@
{#if getQueryParams(value).length > 0} {#if getQueryParams(value).length > 0}
<BindingBuilder <BindingBuilder
bind:customParams={tmpQueryParams} bind:customParams={tmpQueryParams}
bindings={getQueryParams(value)} queryBindings={getQueryParams(value)}
bind:bindableOptions={bindings} bind:bindings
/> />
{/if} {/if}
<IntegrationQueryEditor <IntegrationQueryEditor

View File

@ -14,6 +14,7 @@
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte" import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
import { generate } from "shortid" import { generate } from "shortid"
import { getValidOperatorsForType, OperatorOptions } from "constants/lucene" import { getValidOperatorsForType, OperatorOptions } from "constants/lucene"
import { getFields } from "helpers/searchFields"
export let schemaFields export let schemaFields
export let filters = [] export let filters = []
@ -21,11 +22,8 @@
export let panel = ClientBindingPanel export let panel = ClientBindingPanel
export let allowBindings = true export let allowBindings = true
const BannedTypes = ["link", "attachment", "formula", "json", "jsonarray"] $: enrichedSchemaFields = getFields(schemaFields || [])
$: fieldOptions = enrichedSchemaFields.map(field => field.name) || []
$: fieldOptions = (schemaFields ?? [])
.filter(field => !BannedTypes.includes(field.type))
.map(field => field.name)
$: valueTypeOptions = allowBindings ? ["Value", "Binding"] : ["Value"] $: valueTypeOptions = allowBindings ? ["Value", "Binding"] : ["Value"]
const addFilter = () => { const addFilter = () => {
@ -53,7 +51,7 @@
const onFieldChange = (expression, field) => { const onFieldChange = (expression, field) => {
// Update the field type // Update the field type
expression.type = schemaFields.find(x => x.name === field)?.type expression.type = enrichedSchemaFields.find(x => x.name === field)?.type
// Ensure a valid operator is set // Ensure a valid operator is set
const validOperators = getValidOperatorsForType(expression.type).map( const validOperators = getValidOperatorsForType(expression.type).map(
@ -85,7 +83,7 @@
} }
const getFieldOptions = field => { const getFieldOptions = field => {
const schema = schemaFields.find(x => x.name === field) const schema = enrichedSchemaFields.find(x => x.name === field)
return schema?.constraints?.inclusion || [] return schema?.constraints?.inclusion || []
} }
</script> </script>
@ -133,7 +131,7 @@
{bindings} {bindings}
on:change={event => (filter.value = event.detail)} on:change={event => (filter.value = event.detail)}
/> />
{:else if ["string", "longform", "number"].includes(filter.type)} {:else if ["string", "longform", "number", "formula"].includes(filter.type)}
<Input disabled={filter.noValue} bind:value={filter.value} /> <Input disabled={filter.noValue} bind:value={filter.value} />
{:else if ["options", "array"].includes(filter.type)} {:else if ["options", "array"].includes(filter.type)}
<Combobox <Combobox

View File

@ -0,0 +1,15 @@
<script>
import { Select } from "@budibase/bbui"
import { datasources } from "stores/backend"
export let value = null
$: dataSources = $datasources.list
.filter(ds => ds.source === "S3" && !ds.config?.endpoint)
.map(ds => ({
label: ds.name,
value: ds._id,
}))
</script>
<Select options={dataSources} {value} on:change />

View File

@ -0,0 +1,47 @@
<script>
import { Multiselect } from "@budibase/bbui"
import {
getDatasourceForProvider,
getSchemaForDatasource,
} from "builderStore/dataBinding"
import { currentAsset } from "builderStore"
import { tables } from "stores/backend"
import { createEventDispatcher } from "svelte"
import { getFields } from "helpers/searchFields"
export let componentInstance = {}
export let value = ""
export let placeholder
const dispatch = createEventDispatcher()
$: datasource = getDatasourceForProvider($currentAsset, componentInstance)
$: schema = getSchemaForDatasource($currentAsset, datasource).schema
$: options = getOptions(datasource, schema || {})
$: boundValue = getSelectedOption(value, options)
function getOptions(ds, dsSchema) {
let base = Object.values(dsSchema)
if (!ds?.tableId) {
return base
}
const currentTable = $tables.list.find(table => table._id === ds.tableId)
return getFields(base, { allowLinks: currentTable.sql }).map(
field => field.name
)
}
function getSelectedOption(selectedOptions, allOptions) {
// Fix the hardcoded default string value
if (!Array.isArray(selectedOptions)) {
selectedOptions = []
}
return selectedOptions.filter(val => allOptions.indexOf(val) !== -1)
}
const setValue = value => {
boundValue = getSelectedOption(value.detail, options)
dispatch("change", boundValue)
}
</script>
<Multiselect {placeholder} value={boundValue} on:change={setValue} {options} />

View File

@ -1,5 +1,6 @@
import { Checkbox, Select, Stepper } from "@budibase/bbui" import { Checkbox, Select, Stepper } from "@budibase/bbui"
import DataSourceSelect from "./DataSourceSelect.svelte" import DataSourceSelect from "./DataSourceSelect.svelte"
import S3DataSourceSelect from "./S3DataSourceSelect.svelte"
import DataProviderSelect from "./DataProviderSelect.svelte" import DataProviderSelect from "./DataProviderSelect.svelte"
import ButtonActionEditor from "./ButtonActionEditor/ButtonActionEditor.svelte" import ButtonActionEditor from "./ButtonActionEditor/ButtonActionEditor.svelte"
import TableSelect from "./TableSelect.svelte" import TableSelect from "./TableSelect.svelte"
@ -7,6 +8,7 @@ import ColorPicker from "./ColorPicker.svelte"
import { IconSelect } from "./IconSelect" import { IconSelect } from "./IconSelect"
import FieldSelect from "./FieldSelect.svelte" import FieldSelect from "./FieldSelect.svelte"
import MultiFieldSelect from "./MultiFieldSelect.svelte" import MultiFieldSelect from "./MultiFieldSelect.svelte"
import SearchFieldSelect from "./SearchFieldSelect.svelte"
import SchemaSelect from "./SchemaSelect.svelte" import SchemaSelect from "./SchemaSelect.svelte"
import SectionSelect from "./SectionSelect.svelte" import SectionSelect from "./SectionSelect.svelte"
import NavigationEditor from "./NavigationEditor/NavigationEditor.svelte" import NavigationEditor from "./NavigationEditor/NavigationEditor.svelte"
@ -21,6 +23,7 @@ const componentMap = {
text: DrawerBindableCombobox, text: DrawerBindableCombobox,
select: Select, select: Select,
dataSource: DataSourceSelect, dataSource: DataSourceSelect,
"dataSource/s3": S3DataSourceSelect,
dataProvider: DataProviderSelect, dataProvider: DataProviderSelect,
boolean: Checkbox, boolean: Checkbox,
number: Stepper, number: Stepper,
@ -30,6 +33,7 @@ const componentMap = {
icon: IconSelect, icon: IconSelect,
field: FieldSelect, field: FieldSelect,
multifield: MultiFieldSelect, multifield: MultiFieldSelect,
searchfield: SearchFieldSelect,
options: OptionsEditor, options: OptionsEditor,
schema: SchemaSelect, schema: SchemaSelect,
section: SectionSelect, section: SectionSelect,

View File

@ -1,16 +1,14 @@
<script> <script>
import { Label, Select } from "@budibase/bbui" import { Label, Select } from "@budibase/bbui"
import { permissions, roles } from "stores/backend" import { permissions, roles } from "stores/backend"
import { onMount } from "svelte"
import { Roles } from "constants/backend" import { Roles } from "constants/backend"
export let query export let query
export let saveId
export let label export let label
$: updateRole(roleId, saveId) $: getPermissions(query)
let roleId, loaded let roleId, loaded, fetched
async function updateRole(role, id) { async function updateRole(role, id) {
roleId = role roleId = role
@ -26,19 +24,23 @@
} }
} }
onMount(async () => { async function getPermissions(queryToFetch) {
if (!query || !query._id) { if (fetched?._id === queryToFetch?._id) {
return
}
fetched = queryToFetch
if (!queryToFetch || !queryToFetch._id) {
roleId = Roles.BASIC roleId = Roles.BASIC
loaded = true loaded = true
return return
} }
try { try {
roleId = (await permissions.forResource(query._id))["read"] roleId = (await permissions.forResource(queryToFetch._id))["read"]
} catch (err) { } catch (err) {
roleId = Roles.BASIC roleId = Roles.BASIC
} }
loaded = true loaded = true
}) }
</script> </script>
{#if loaded} {#if loaded}

View File

@ -7,27 +7,24 @@
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte" import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
export let bindable = true export let bindable = true
export let queryBindings = []
export let bindings = [] export let bindings = []
export let bindableOptions = []
export let customParams = {} export let customParams = {}
function newQueryBinding() { function newQueryBinding() {
bindings = [...bindings, {}] queryBindings = [...queryBindings, {}]
} }
function deleteQueryBinding(idx) { function deleteQueryBinding(idx) {
bindings.splice(idx, 1) queryBindings.splice(idx, 1)
bindings = bindings queryBindings = queryBindings
} }
// This is necessary due to the way readable and writable bindings are stored. // This is necessary due to the way readable and writable bindings are stored.
// The readable binding in the UI gets converted to a UUID value that the client understands // The readable binding in the UI gets converted to a UUID value that the client understands
// for parsing, then converted back so we can display it the readable form in the UI // for parsing, then converted back so we can display it the readable form in the UI
function onBindingChange(param, valueToParse) { function onBindingChange(param, valueToParse) {
customParams[param] = readableToRuntimeBinding( customParams[param] = readableToRuntimeBinding(bindings, valueToParse)
bindableOptions,
valueToParse
)
} }
</script> </script>
@ -49,7 +46,7 @@
{/if} {/if}
</Body> </Body>
<div class="bindings" class:bindable> <div class="bindings" class:bindable>
{#each bindings as binding, idx} {#each queryBindings as binding, idx}
<Input <Input
placeholder="Binding Name" placeholder="Binding Name"
thin thin
@ -69,10 +66,10 @@
thin thin
on:change={evt => onBindingChange(binding.name, evt.detail)} on:change={evt => onBindingChange(binding.name, evt.detail)}
value={runtimeToReadableBinding( value={runtimeToReadableBinding(
bindableOptions, bindings,
customParams?.[binding.name] customParams?.[binding.name]
)} )}
{bindableOptions} {bindings}
/> />
{:else} {:else}
<Icon hoverable name="Close" on:click={() => deleteQueryBinding(idx)} /> <Icon hoverable name="Close" on:click={() => deleteQueryBinding(idx)} />

View File

@ -120,7 +120,7 @@
config={integrationInfo.extra} config={integrationInfo.extra}
/> />
{/if} {/if}
<BindingBuilder bind:bindings={query.parameters} bindable={false} /> <BindingBuilder bind:queryBindings={query.parameters} bindable={false} />
{/if} {/if}
</div> </div>
{#if shouldShowQueryConfig} {#if shouldShowQueryConfig}

View File

@ -1,101 +1,46 @@
<script> <script>
import { writable, get as svelteGet } from "svelte/store" import { writable, get as svelteGet } from "svelte/store"
import { notifications, Input, ModalContent, Dropzone } from "@budibase/bbui" import { notifications, Input, ModalContent, Dropzone } from "@budibase/bbui"
import { store, automationStore, hostingStore } from "builderStore" import { store, automationStore } from "builderStore"
import { admin, auth } from "stores/portal" import { apps, admin, auth } from "stores/portal"
import { string, mixed, object } from "yup"
import api, { get, post } from "builderStore/api" import api, { get, post } from "builderStore/api"
import analytics, { Events } from "analytics" import analytics, { Events } from "analytics"
import { onMount } from "svelte" import { onMount } from "svelte"
import { capitalise } from "helpers"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { APP_NAME_REGEX } from "constants" import { createValidationStore } from "helpers/validation/yup"
import TemplateList from "./TemplateList.svelte" import * as appValidation from "helpers/validation/yup/app"
export let template export let template
export let inline
const values = writable({ name: null }) const values = writable({ name: "", url: null })
const errors = writable({}) const validation = createValidationStore()
const touched = writable({}) $: validation.check($values)
const validator = {
name: string()
.trim()
.required("Your application must have a name")
.matches(
APP_NAME_REGEX,
"App name must be letters, numbers and spaces only"
),
file: template?.fromFile
? mixed().required("Please choose a file to import")
: null,
}
let submitting = false
let valid = false
let initialTemplateInfo = template?.fromFile || template?.key
$: checkValidity($values, validator)
$: showTemplateSelection = !template && !initialTemplateInfo
onMount(async () => { onMount(async () => {
await hostingStore.actions.fetchDeployedApps() await setupValidation()
const existingAppNames = svelteGet(hostingStore).deployedAppNames
validator.name = string()
.trim()
.required("Your application must have a name")
.matches(APP_NAME_REGEX, "App name must be letters and numbers only")
.test(
"non-existing-app-name",
"Another app with the same name already exists",
value => {
return !existingAppNames.some(
appName => appName.toLowerCase() === value.toLowerCase()
)
}
)
}) })
const checkValidity = async (values, validator) => { const setupValidation = async () => {
const obj = object().shape(validator) const applications = svelteGet(apps)
Object.keys(validator).forEach(key => ($errors[key] = null)) appValidation.name(validation, { apps: applications })
if (template?.fromFile && values.file == null) { appValidation.url(validation, { apps: applications })
valid = false appValidation.file(validation, { template })
return // init validation
} validation.check($values)
try {
await obj.validate(values, { abortEarly: false })
} catch (validationErrors) {
validationErrors.inner.forEach(error => {
$errors[error.path] = capitalise(error.message)
})
}
valid = await obj.isValid(values)
} }
async function createNewApp() { async function createNewApp() {
const templateToUse = Object.keys(template).length === 0 ? null : template
submitting = true
// Check a template exists if we are important
if (templateToUse?.fromFile && !$values.file) {
$errors.file = "Please choose a file to import"
valid = false
submitting = false
return false
}
try { try {
// Create form data to create app // Create form data to create app
let data = new FormData() let data = new FormData()
data.append("name", $values.name.trim()) data.append("name", $values.name.trim())
data.append("useTemplate", templateToUse != null) if ($values.url) {
if (templateToUse) { data.append("url", $values.url.trim())
data.append("templateName", templateToUse.name) }
data.append("templateKey", templateToUse.key) data.append("useTemplate", template != null)
if (template) {
data.append("templateName", template.name)
data.append("templateKey", template.key)
data.append("templateFile", $values.file) data.append("templateFile", $values.file)
} }
@ -109,7 +54,7 @@
analytics.captureEvent(Events.APP.CREATED, { analytics.captureEvent(Events.APP.CREATED, {
name: $values.name, name: $values.name,
appId: appJson.instance._id, appId: appJson.instance._id,
templateToUse, templateToUse: template,
}) })
// Select Correct Application/DB in prep for creating user // Select Correct Application/DB in prep for creating user
@ -137,68 +82,51 @@
} catch (error) { } catch (error) {
console.error(error) console.error(error)
notifications.error(error) notifications.error(error)
submitting = false
} }
} }
async function onCancel() { // auto add slash to url
template = null $: {
await auth.setInitInfo({}) if ($values.url && !$values.url.startsWith("/")) {
$values.url = `/${$values.url}`
}
} }
</script> </script>
{#if showTemplateSelection} <ModalContent
<ModalContent title={"Create your app"}
title={"Get started quickly"} confirmText={template?.fromFile ? "Import app" : "Create app"}
showConfirmButton={false} onConfirm={createNewApp}
size="L" disabled={!$validation.valid}
onConfirm={() => { >
template = {} {#if template?.fromFile}
return false <Dropzone
}} error={$validation.touched.file && $validation.errors.file}
showCancelButton={!inline} gallery={false}
showCloseIcon={!inline} label="File to import"
> value={[$values.file]}
<TemplateList on:change={e => {
onSelect={(selected, { useImport } = {}) => { $values.file = e.detail?.[0]
if (!selected) { $validation.touched.file = true
template = useImport ? { fromFile: true } : {}
return
}
template = selected
}} }}
/> />
</ModalContent> {/if}
{:else} <Input
<ModalContent bind:value={$values.name}
title={"Name your app"} error={$validation.touched.name && $validation.errors.name}
confirmText={template?.fromFile ? "Import app" : "Create app"} on:blur={() => ($validation.touched.name = true)}
onConfirm={createNewApp} label="Name"
onCancel={inline ? onCancel : null} placeholder={$auth.user.firstName
cancelText={inline ? "Back" : undefined} ? `${$auth.user.firstName}s app`
showCloseIcon={!inline} : "My app"}
disabled={!valid} />
> <Input
{#if template?.fromFile} bind:value={$values.url}
<Dropzone error={$validation.touched.url && $validation.errors.url}
error={$touched.file && $errors.file} on:blur={() => ($validation.touched.url = true)}
gallery={false} label="URL"
label="File to import" placeholder={$values.name
value={[$values.file]} ? "/" + encodeURIComponent($values.name).toLowerCase()
on:change={e => { : "/"}
$values.file = e.detail?.[0] />
$touched.file = true </ModalContent>
}}
/>
{/if}
<Input
bind:value={$values.name}
error={$touched.name && $errors.name}
on:blur={() => ($touched.name = true)}
label="Name"
placeholder={$auth.user.firstName
? `${$auth.user.firstName}'s app`
: "My app"}
/>
</ModalContent>
{/if}

View File

@ -1,69 +0,0 @@
<script>
import { Heading, Layout, Icon } from "@budibase/bbui"
export let onSelect
</script>
<Layout gap="XS" noPadding>
<div class="template start-from-scratch" on:click={() => onSelect(null)}>
<div
class="background-icon"
style={`background: rgb(50, 50, 50); color: white;`}
>
<Icon name="Add" />
</div>
<Heading size="XS">Start from scratch</Heading>
<p class="detail">BLANK</p>
</div>
<div
class="template import"
on:click={() => onSelect(null, { useImport: true })}
>
<div
class="background-icon"
style={`background: rgb(50, 50, 50); color: white;`}
>
<Icon name="Add" />
</div>
<Heading size="XS">Import an app</Heading>
<p class="detail">BLANK</p>
</div>
</Layout>
<style>
.background-icon {
padding: 10px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
width: 18px;
color: white;
}
.template {
min-height: 60px;
display: grid;
grid-gap: var(--layout-s);
grid-template-columns: auto 1fr auto;
border: 1px solid #494949;
align-items: center;
cursor: pointer;
border-radius: 4px;
background: var(--background-alt);
padding: 8px 16px;
}
.detail {
text-align: right;
}
.start-from-scratch {
background: var(--spectrum-global-color-gray-50);
margin-top: 20px;
}
.import {
background: var(--spectrum-global-color-gray-50);
}
</style>

View File

@ -1,120 +1,75 @@
<script> <script>
import { writable, get as svelteGet } from "svelte/store" import { writable, get as svelteGet } from "svelte/store"
import { import { notifications, Input, ModalContent, Body } from "@budibase/bbui"
notifications,
Input,
Modal,
ModalContent,
Body,
} from "@budibase/bbui"
import { hostingStore } from "builderStore"
import { apps } from "stores/portal" import { apps } from "stores/portal"
import { string, object } from "yup"
import { onMount } from "svelte" import { onMount } from "svelte"
import { capitalise } from "helpers" import { createValidationStore } from "helpers/validation/yup"
import { APP_NAME_REGEX } from "constants" import * as appValidation from "helpers/validation/yup/app"
const values = writable({ name: null })
const errors = writable({})
const touched = writable({})
const validator = {
name: string()
.trim()
.required("Your application must have a name")
.matches(
APP_NAME_REGEX,
"App name must be letters, numbers and spaces only"
),
}
export let app export let app
let modal const values = writable({ name: "", url: null })
let valid = false const validation = createValidationStore()
let dirty = false $: validation.check($values)
$: checkValidity($values, validator)
$: {
// prevent validation by setting name to undefined without an app
if (app) {
$values.name = app?.name
}
}
onMount(async () => { onMount(async () => {
await hostingStore.actions.fetchDeployedApps() $values.name = app.name
const existingAppNames = svelteGet(hostingStore).deployedAppNames $values.url = app.url
validator.name = string() setupValidation()
.trim()
.required("Your application must have a name")
.matches(
APP_NAME_REGEX,
"App name must be letters, numbers and spaces only"
)
.test(
"non-existing-app-name",
"Another app with the same name already exists",
value => {
return !existingAppNames.some(
appName => dirty && appName.toLowerCase() === value.toLowerCase()
)
}
)
}) })
const checkValidity = async (values, validator) => { const setupValidation = async () => {
const obj = object().shape(validator) const applications = svelteGet(apps)
Object.keys(validator).forEach(key => ($errors[key] = null)) appValidation.name(validation, { apps: applications, currentApp: app })
try { appValidation.url(validation, { apps: applications, currentApp: app })
await obj.validate(values, { abortEarly: false }) // init validation
} catch (validationErrors) { validation.check($values)
validationErrors.inner.forEach(error => {
$errors[error.path] = capitalise(error.message)
})
}
valid = await obj.isValid(values)
} }
async function updateApp() { async function updateApp() {
try { try {
// Update App // Update App
await apps.update(app.instance._id, { name: $values.name.trim() }) const body = {
hide() name: $values.name.trim(),
}
if ($values.url) {
body.url = $values.url.trim()
}
await apps.update(app.instance._id, body)
} catch (error) { } catch (error) {
console.error(error) console.error(error)
notifications.error(error) notifications.error(error)
} }
} }
export const show = () => { // auto add slash to url
modal.show() $: {
} if ($values.url && !$values.url.startsWith("/")) {
export const hide = () => { $values.url = `/${$values.url}`
modal.hide() }
}
const onCancel = () => {
hide()
}
const onShow = () => {
dirty = false
} }
</script> </script>
<Modal bind:this={modal} on:hide={onCancel} on:show={onShow}> <ModalContent
<ModalContent title={"Edit app"}
title={"Edit app"} confirmText={"Save"}
confirmText={"Save"} onConfirm={updateApp}
onConfirm={updateApp} disabled={!$validation.valid}
disabled={!(valid && dirty)} >
> <Body size="S">Update the name of your app.</Body>
<Body size="S">Update the name of your app.</Body> <Input
<Input bind:value={$values.name}
bind:value={$values.name} error={$validation.touched.name && $validation.errors.name}
error={$touched.name && $errors.name} on:blur={() => ($validation.touched.name = true)}
on:blur={() => ($touched.name = true)} label="Name"
on:change={() => (dirty = true)} />
label="Name" <Input
/> bind:value={$values.url}
</ModalContent> error={$validation.touched.url && $validation.errors.url}
</Modal> on:blur={() => ($validation.touched.url = true)}
label="URL"
placeholder={$values.name
? "/" + encodeURIComponent($values.name).toLowerCase()
: "/"}
/>
</ModalContent>

View File

@ -148,20 +148,23 @@ export const RelationshipTypes = {
} }
export const ALLOWABLE_STRING_OPTIONS = [FIELDS.STRING, FIELDS.OPTIONS] export const ALLOWABLE_STRING_OPTIONS = [FIELDS.STRING, FIELDS.OPTIONS]
export const ALLOWABLE_STRING_TYPES = ALLOWABLE_STRING_OPTIONS.map( export const ALLOWABLE_STRING_TYPES = ALLOWABLE_STRING_OPTIONS.map(
opt => opt.type opt => opt.type
) )
export const ALLOWABLE_NUMBER_OPTIONS = [FIELDS.NUMBER, FIELDS.BOOLEAN] export const ALLOWABLE_NUMBER_OPTIONS = [FIELDS.NUMBER, FIELDS.BOOLEAN]
export const ALLOWABLE_NUMBER_TYPES = ALLOWABLE_NUMBER_OPTIONS.map( export const ALLOWABLE_NUMBER_TYPES = ALLOWABLE_NUMBER_OPTIONS.map(
opt => opt.type opt => opt.type
) )
export const SWITCHABLE_TYPES = ALLOWABLE_NUMBER_TYPES.concat( export const ALLOWABLE_JSON_OPTIONS = [FIELDS.JSON, FIELDS.ARRAY]
ALLOWABLE_STRING_TYPES export const ALLOWABLE_JSON_TYPES = ALLOWABLE_JSON_OPTIONS.map(opt => opt.type)
)
export const SWITCHABLE_TYPES = [
...ALLOWABLE_STRING_TYPES,
...ALLOWABLE_NUMBER_TYPES,
...ALLOWABLE_JSON_TYPES,
]
export const IntegrationTypes = { export const IntegrationTypes = {
POSTGRES: "POSTGRES", POSTGRES: "POSTGRES",
@ -177,6 +180,7 @@ export const IntegrationTypes = {
ARANGODB: "ARANGODB", ARANGODB: "ARANGODB",
ORACLE: "ORACLE", ORACLE: "ORACLE",
INTERNAL: "INTERNAL", INTERNAL: "INTERNAL",
GOOGLE_SHEETS: "GOOGLE_SHEETS",
} }
export const IntegrationNames = { export const IntegrationNames = {
@ -193,6 +197,7 @@ export const IntegrationNames = {
[IntegrationTypes.ARANGODB]: "ArangoDB", [IntegrationTypes.ARANGODB]: "ArangoDB",
[IntegrationTypes.ORACLE]: "Oracle", [IntegrationTypes.ORACLE]: "Oracle",
[IntegrationTypes.INTERNAL]: "Internal", [IntegrationTypes.INTERNAL]: "Internal",
[IntegrationTypes.GOOGLE_SHEETS]: "Google Sheets",
} }
export const SchemaTypeOptions = [ export const SchemaTypeOptions = [
@ -229,3 +234,11 @@ export const PaginationLocations = [
{ label: "Query parameters", value: "query" }, { label: "Query parameters", value: "query" },
{ label: "Request body", value: "body" }, { label: "Request body", value: "body" },
] ]
export const BannedSearchTypes = [
"link",
"attachment",
"formula",
"json",
"jsonarray",
]

View File

@ -15,6 +15,22 @@ export const AppStatus = {
DEPLOYED: "published", DEPLOYED: "published",
} }
export const IntegrationNames = {
POSTGRES: "PostgreSQL",
MONGODB: "MongoDB",
COUCHDB: "CouchDB",
S3: "S3",
MYSQL: "MySQL",
REST: "REST",
DYNAMODB: "DynamoDB",
ELASTICSEARCH: "ElasticSearch",
SQL_SERVER: "SQL Server",
AIRTABLE: "Airtable",
ARANGODB: "ArangoDB",
ORACLE: "Oracle",
GOOGLE_SHEETS: "Google Sheets",
}
// fields on the user table that cannot be edited // fields on the user table that cannot be edited
export const UNEDITABLE_USER_FIELDS = [ export const UNEDITABLE_USER_FIELDS = [
"email", "email",
@ -36,4 +52,7 @@ export const LAYOUT_NAMES = {
export const BUDIBASE_INTERNAL_DB = "bb_internal" export const BUDIBASE_INTERNAL_DB = "bb_internal"
// one or more word characters and whitespace
export const APP_NAME_REGEX = /^[\w\s]+$/ export const APP_NAME_REGEX = /^[\w\s]+$/
// zero or more non-whitespace characters
export const APP_URL_REGEX = /^\S*$/

View File

@ -59,24 +59,26 @@ export const NoEmptyFilterStrings = [
*/ */
export const getValidOperatorsForType = type => { export const getValidOperatorsForType = type => {
const Op = OperatorOptions const Op = OperatorOptions
const stringOps = [
Op.Equals,
Op.NotEquals,
Op.StartsWith,
Op.Like,
Op.Empty,
Op.NotEmpty,
]
const numOps = [
Op.Equals,
Op.NotEquals,
Op.MoreThan,
Op.LessThan,
Op.Empty,
Op.NotEmpty,
]
if (type === "string") { if (type === "string") {
return [ return stringOps
Op.Equals,
Op.NotEquals,
Op.StartsWith,
Op.Like,
Op.Empty,
Op.NotEmpty,
]
} else if (type === "number") { } else if (type === "number") {
return [ return numOps
Op.Equals,
Op.NotEquals,
Op.MoreThan,
Op.LessThan,
Op.Empty,
Op.NotEmpty,
]
} else if (type === "options") { } else if (type === "options") {
return [Op.Equals, Op.NotEquals, Op.Empty, Op.NotEmpty] return [Op.Equals, Op.NotEquals, Op.Empty, Op.NotEmpty]
} else if (type === "array") { } else if (type === "array") {
@ -84,23 +86,11 @@ export const getValidOperatorsForType = type => {
} else if (type === "boolean") { } else if (type === "boolean") {
return [Op.Equals, Op.NotEquals, Op.Empty, Op.NotEmpty] return [Op.Equals, Op.NotEquals, Op.Empty, Op.NotEmpty]
} else if (type === "longform") { } else if (type === "longform") {
return [ return stringOps
Op.Equals,
Op.NotEquals,
Op.StartsWith,
Op.Like,
Op.Empty,
Op.NotEmpty,
]
} else if (type === "datetime") { } else if (type === "datetime") {
return [ return numOps
Op.Equals, } else if (type === "formula") {
Op.NotEquals, return stringOps.concat([Op.MoreThan, Op.LessThan])
Op.MoreThan,
Op.LessThan,
Op.Empty,
Op.NotEmpty,
]
} }
return [] return []
} }

View File

@ -0,0 +1,34 @@
import { tables } from "../stores/backend"
import { BannedSearchTypes } from "../constants/backend"
import { get } from "svelte/store"
export function getTableFields(linkField) {
const table = get(tables).list.find(table => table._id === linkField.tableId)
if (!table || !table.sql) {
return []
}
const linkFields = getFields(Object.values(table.schema), {
allowLinks: false,
})
return linkFields.map(field => ({
...field,
name: `${table.name}.${field.name}`,
}))
}
export function getFields(fields, { allowLinks } = { allowLinks: true }) {
let filteredFields = fields.filter(
field => !BannedSearchTypes.includes(field.type)
)
if (allowLinks) {
const linkFields = fields.filter(field => field.type === "link")
for (let linkField of linkFields) {
// only allow one depth of SQL relationship filtering
filteredFields = filteredFields.concat(getTableFields(linkField))
}
}
const staticFormulaFields = fields.filter(
field => field.type === "formula" && field.formulaType === "static"
)
return filteredFields.concat(staticFormulaFields)
}

View File

@ -1,5 +1,7 @@
import { writable, derived } from "svelte/store" import { writable, derived } from "svelte/store"
// DEPRECATED - Use the yup based validators for future validation
export function createValidationStore(initialValue, ...validators) { export function createValidationStore(initialValue, ...validators) {
let touched = false let touched = false

View File

@ -1,3 +1,5 @@
// TODO: Convert to yup based validators
export function emailValidator(value) { export function emailValidator(value) {
return ( return (
(value && (value &&

View File

@ -0,0 +1,83 @@
import { string, mixed } from "yup"
import { APP_NAME_REGEX, APP_URL_REGEX } from "constants"
export const name = (validation, { apps, currentApp } = { apps: [] }) => {
validation.addValidator(
"name",
string()
.trim()
.required("Your application must have a name")
.matches(
APP_NAME_REGEX,
"App name must be letters, numbers and spaces only"
)
.test(
"non-existing-app-name",
"Another app with the same name already exists",
value => {
if (!value) {
// exit early, above validator will fail
return true
}
if (currentApp) {
// filter out the current app if present
apps = apps.filter(app => app.appId !== currentApp.appId)
}
return !apps
.map(app => app.name)
.some(appName => appName.toLowerCase() === value.toLowerCase())
}
)
)
}
export const url = (validation, { apps, currentApp } = { apps: [] }) => {
validation.addValidator(
"url",
string()
.nullable()
.matches(APP_URL_REGEX, "App URL must not contain spaces")
.test(
"non-existing-app-url",
"Another app with the same URL already exists",
value => {
// url is nullable
if (!value) {
return true
}
if (currentApp) {
// filter out the current app if present
apps = apps.filter(app => app.appId !== currentApp.appId)
}
return !apps
.map(app => app.url)
.some(appUrl => appUrl?.toLowerCase() === value.toLowerCase())
}
)
.test("valid-url", "Not a valid URL", value => {
// url is nullable
if (!value) {
return true
}
// make it clear that this is a url path and cannot be a full url
return (
value.startsWith("/") &&
!value.includes("http") &&
!value.includes("www") &&
!value.includes(".") &&
value.length > 1 // just '/' is not valid
)
})
)
}
export const file = (validation, { template } = {}) => {
const templateToUse =
template && Object.keys(template).length === 0 ? null : template
validation.addValidator(
"file",
templateToUse?.fromFile
? mixed().required("Please choose a file to import")
: null
)
}

View File

@ -0,0 +1,66 @@
import { capitalise } from "helpers"
import { object } from "yup"
import { writable, get } from "svelte/store"
import { notifications } from "@budibase/bbui"
export const createValidationStore = () => {
const DEFAULT = {
errors: {},
touched: {},
valid: false,
}
const validator = {}
const validation = writable(DEFAULT)
const addValidator = (propertyName, propertyValidator) => {
if (!propertyValidator || !propertyName) {
return
}
validator[propertyName] = propertyValidator
}
const check = async values => {
const obj = object().shape(validator)
// clear the previous errors
const properties = Object.keys(validator)
properties.forEach(property => (get(validation).errors[property] = null))
let validationError = false
try {
await obj.validate(values, { abortEarly: false })
} catch (error) {
if (!error.inner) {
notifications.error("Unexpected validation error", error)
validationError = true
} else {
error.inner.forEach(err => {
validation.update(store => {
store.errors[err.path] = capitalise(err.message)
return store
})
})
}
}
let valid
if (properties.length && !validationError) {
valid = await obj.isValid(values)
} else {
// don't say valid until validators have been loaded
valid = false
}
validation.update(store => {
store.valid = valid
return store
})
}
return {
subscribe: validation.subscribe,
set: validation.set,
check,
addValidator,
}
}

View File

@ -2,6 +2,12 @@
import { isActive, redirect, params } from "@roxi/routify" import { isActive, redirect, params } from "@roxi/routify"
import { admin, auth } from "stores/portal" import { admin, auth } from "stores/portal"
import { onMount } from "svelte" import { onMount } from "svelte"
import {
Cookies,
getCookie,
removeCookie,
setCookie,
} from "builderStore/cookies"
let loaded = false let loaded = false
@ -55,7 +61,7 @@
await auth.setInitInfo({ init_template: $params["?template"] }) await auth.setInitInfo({ init_template: $params["?template"] })
} }
await auth.checkAuth() await auth.getSelf()
await admin.init() await admin.init()
if (useAccountPortal && multiTenancyEnabled) { if (useAccountPortal && multiTenancyEnabled) {
@ -67,6 +73,24 @@
$: { $: {
const apiReady = $admin.loaded && $auth.loaded const apiReady = $admin.loaded && $auth.loaded
// firstly, set the return url
if (
loaded &&
apiReady &&
!$auth.user &&
!getCookie(Cookies.ReturnUrl) &&
// logout triggers a page refresh, so we don't want to set the return url
!$auth.postLogout &&
// don't set the return url on pre-login pages
!$isActive("./auth") &&
!$isActive("./invite") &&
!$isActive("./admin")
) {
const url = window.location.pathname
setCookie(Cookies.ReturnUrl, url)
}
// if tenant is not set go to it // if tenant is not set go to it
if ( if (
loaded && loaded &&
@ -90,13 +114,20 @@
!$isActive("./invite") && !$isActive("./invite") &&
!$isActive("./admin") !$isActive("./admin")
) { ) {
const returnUrl = encodeURIComponent(window.location.pathname) $redirect("./auth")
$redirect("./auth?", { returnUrl })
} }
// check if password reset required for user // check if password reset required for user
else if ($auth.user?.forceResetPassword) { else if ($auth.user?.forceResetPassword) {
$redirect("./auth/reset") $redirect("./auth/reset")
} }
// lastly, redirect to the return url if it has been set
else if (loaded && apiReady && $auth.user) {
const returnUrl = getCookie(Cookies.ReturnUrl)
if (returnUrl) {
removeCookie(Cookies.ReturnUrl)
window.location.href = returnUrl
}
}
} }
</script> </script>

View File

@ -19,8 +19,8 @@
import { IntegrationTypes } from "constants/backend" import { IntegrationTypes } from "constants/backend"
import { isEqual } from "lodash" import { isEqual } from "lodash"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import ImportRestQueriesModal from "components/backend/DatasourceNavigator/modals/ImportRestQueriesModal.svelte" import ImportRestQueriesModal from "components/backend/DatasourceNavigator/modals/ImportRestQueriesModal.svelte"
let importQueriesModal let importQueriesModal
let changed let changed

View File

@ -12,7 +12,7 @@
Modal, Modal,
} from "@budibase/bbui" } from "@budibase/bbui"
import { onMount } from "svelte" import { onMount } from "svelte"
import { apps, organisation, auth, admin } from "stores/portal" import { apps, organisation, auth } from "stores/portal"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { AppStatus } from "constants" import { AppStatus } from "constants"
import { gradient } from "actions" import { gradient } from "actions"
@ -34,7 +34,6 @@
const publishedAppsOnly = app => app.status === AppStatus.DEPLOYED const publishedAppsOnly = app => app.status === AppStatus.DEPLOYED
$: publishedApps = $apps.filter(publishedAppsOnly) $: publishedApps = $apps.filter(publishedAppsOnly)
$: isCloud = $admin.cloud
$: userApps = $auth.user?.builder?.global $: userApps = $auth.user?.builder?.global
? publishedApps ? publishedApps
: publishedApps.filter(app => : publishedApps.filter(app =>
@ -42,7 +41,11 @@
) )
function getUrl(app) { function getUrl(app) {
return !isCloud ? `/app/${encodeURIComponent(app.name)}` : `/${app.prodId}` if (app.url) {
return `/app${app.url}`
} else {
return `/${app.prodId}`
}
} }
</script> </script>

View File

@ -10,7 +10,7 @@
notifications, notifications,
Link, Link,
} from "@budibase/bbui" } from "@budibase/bbui"
import { goto, params } from "@roxi/routify" import { goto } from "@roxi/routify"
import { auth, organisation, oidc, admin } from "stores/portal" import { auth, organisation, oidc, admin } from "stores/portal"
import GoogleButton from "./_components/GoogleButton.svelte" import GoogleButton from "./_components/GoogleButton.svelte"
import OIDCButton from "./_components/OIDCButton.svelte" import OIDCButton from "./_components/OIDCButton.svelte"
@ -35,12 +35,8 @@
if ($auth?.user?.forceResetPassword) { if ($auth?.user?.forceResetPassword) {
$goto("./reset") $goto("./reset")
} else { } else {
if ($params["?returnUrl"]) { notifications.success("Logged in successfully")
window.location = decodeURIComponent($params["?returnUrl"]) $goto("../portal")
} else {
notifications.success("Logged in successfully")
$goto("../portal")
}
} }
} catch (err) { } catch (err) {
console.error(err) console.error(err)

View File

@ -31,6 +31,7 @@
} }
onMount(async () => { onMount(async () => {
await auth.getSelf()
await organisation.init() await organisation.init()
}) })
</script> </script>

View File

@ -11,6 +11,7 @@
notifications, notifications,
Body, Body,
Search, Search,
Icon,
} from "@budibase/bbui" } from "@budibase/bbui"
import Spinner from "components/common/Spinner.svelte" import Spinner from "components/common/Spinner.svelte"
import CreateAppModal from "components/start/CreateAppModal.svelte" import CreateAppModal from "components/start/CreateAppModal.svelte"
@ -27,6 +28,7 @@
import AppRow from "components/start/AppRow.svelte" import AppRow from "components/start/AppRow.svelte"
import { AppStatus } from "constants" import { AppStatus } from "constants"
import analytics, { Events } from "analytics" import analytics, { Events } from "analytics"
import Logo from "assets/bb-space-man.svg"
let sortBy = "name" let sortBy = "name"
let template let template
@ -47,7 +49,6 @@
$: filteredApps = enrichedApps.filter(app => $: filteredApps = enrichedApps.filter(app =>
app?.name?.toLowerCase().includes(searchTerm.toLowerCase()) app?.name?.toLowerCase().includes(searchTerm.toLowerCase())
) )
$: isCloud = $admin.cloud
const enrichApps = (apps, user, sortBy) => { const enrichApps = (apps, user, sortBy) => {
const enrichedApps = apps.map(app => ({ const enrichedApps = apps.map(app => ({
@ -78,6 +79,7 @@
} }
const initiateAppCreation = () => { const initiateAppCreation = () => {
template = null
creationModal.show() creationModal.show()
creatingApp = true creatingApp = true
} }
@ -159,12 +161,10 @@
} }
const viewApp = app => { const viewApp = app => {
if (!isCloud && app.deployed) { if (app.url) {
// special case to use the short form name if self hosted window.open(`/app${app.url}`)
window.open(`/app/${encodeURIComponent(app.name)}`)
} else { } else {
const id = app.deployed ? app.prodId : app.devId window.open(`/${app.prodId}`)
window.open(`/${id}`, "_blank")
} }
} }
@ -300,14 +300,26 @@
<div class="buttons"> <div class="buttons">
{#if cloud} {#if cloud}
<Button icon="Export" quiet secondary on:click={initiateAppsExport}> <Button
size="L"
icon="Export"
quiet
secondary
on:click={initiateAppsExport}
>
Export apps Export apps
</Button> </Button>
{/if} {/if}
<Button icon="Import" quiet secondary on:click={initiateAppImport}> <Button
icon="Import"
size="L"
quiet
secondary
on:click={initiateAppImport}
>
Import app Import app
</Button> </Button>
<Button icon="Add" cta on:click={initiateAppCreation}> <Button size="L" icon="Add" cta on:click={initiateAppCreation}>
Create app Create app
</Button> </Button>
</div> </div>
@ -325,6 +337,14 @@
}} }}
class="template-card" class="template-card"
> >
<a
href={item.url}
target="_blank"
class="external-link"
on:click|stopPropagation
>
<Icon name="LinkOut" size="S" />
</a>
<div class="card-body"> <div class="card-body">
<div style="color: {item.background}" class="iconAlign"> <div style="color: {item.background}" class="iconAlign">
<svg <svg
@ -389,9 +409,24 @@
{#if !enrichedApps.length && !creatingApp && loaded} {#if !enrichedApps.length && !creatingApp && loaded}
<div class="empty-wrapper"> <div class="empty-wrapper">
<Modal inline> <div class="centered">
<CreateAppModal {template} inline={true} /> <div class="main">
</Modal> <Layout gap="S" justifyItems="center">
<img class="img-size" alt="logo" src={Logo} />
<div class="new-screen-text">
<Detail size="M">Create a business app in minutes!</Detail>
</div>
<Button on:click={() => initiateAppCreation()} size="M" cta>
<div class="new-screen-button">
<div class="background-icon" style="color: white;">
<Icon name="Add" />
</div>
Create App
</div></Button
>
</Layout>
</div>
</div>
</div> </div>
{/if} {/if}
@ -412,6 +447,11 @@
> >
<CreateAppModal {template} /> <CreateAppModal {template} />
</Modal> </Modal>
<Modal bind:this={updatingModal} padding={false} width="600px">
<UpdateAppModal app={selectedApp} />
</Modal>
<ConfirmDialog <ConfirmDialog
bind:this={deletionModal} bind:this={deletionModal}
title="Confirm deletion" title="Confirm deletion"
@ -438,7 +478,6 @@
Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>? Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>?
</ConfirmDialog> </ConfirmDialog>
<UpdateAppModal app={selectedApp} bind:this={updatingModal} />
<ChooseIconModal app={selectedApp} bind:this={iconModal} /> <ChooseIconModal app={selectedApp} bind:this={iconModal} />
<style> <style>
@ -474,16 +513,20 @@
} }
.grid { .grid {
height: 200px;
display: grid; display: grid;
overflow: hidden;
grid-gap: var(--spacing-xl); grid-gap: var(--spacing-xl);
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
grid-template-rows: minmax(70px, 1fr) minmax(100px, 1fr) minmax(0px, 0);
} }
.template-card { .template-card {
height: 80px; height: 70px;
border-radius: var(--border-radius-s); border-radius: var(--border-radius-s);
border: 1px solid var(--spectrum-global-color-gray-300); border: 1px solid var(--spectrum-global-color-gray-300);
cursor: pointer; cursor: pointer;
display: flex; display: flex;
position: relative;
} }
.template-card:hover { .template-card:hover {
@ -494,6 +537,18 @@
align-items: center; align-items: center;
padding: 12px; padding: 12px;
} }
.external-link {
position: absolute;
top: 5px;
right: 5px;
color: var(--spectrum-global-color-gray-300);
z-index: 99;
}
.external-link:hover {
color: var(--spectrum-global-color-gray-500);
}
.iconAlign { .iconAlign {
padding: 0 0 0 var(--spacing-m); padding: 0 0 0 var(--spacing-m);
display: inline-block; display: inline-block;
@ -533,4 +588,42 @@
justify-content: center; justify-content: center;
align-items: center; align-items: center;
} }
.centered {
width: calc(100% - 350px);
height: calc(100% - 100px);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.main {
width: 300px;
}
.new-screen-text {
width: 160px;
text-align: center;
color: #2c2c2c;
font-weight: 600;
}
.new-screen-button {
margin-left: 5px;
height: 20px;
width: 100px;
display: flex;
align-items: center;
}
.img-size {
width: 160px;
height: 160px;
}
.background-icon {
margin-top: 4px;
margin-right: 4px;
}
</style> </style>

View File

@ -9,6 +9,7 @@ export function createAuthStore() {
tenantId: "default", tenantId: "default",
tenantSet: false, tenantSet: false,
loaded: false, loaded: false,
postLogout: false,
}) })
const store = derived(auth, $store => { const store = derived(auth, $store => {
let initials = null let initials = null
@ -34,6 +35,7 @@ export function createAuthStore() {
tenantId: $store.tenantId, tenantId: $store.tenantId,
tenantSet: $store.tenantSet, tenantSet: $store.tenantSet,
loaded: $store.loaded, loaded: $store.loaded,
postLogout: $store.postLogout,
initials, initials,
isAdmin, isAdmin,
isBuilder, isBuilder,
@ -89,6 +91,13 @@ export function createAuthStore() {
return info return info
} }
async function setPostLogout() {
auth.update(store => {
store.postLogout = true
return store
})
}
async function getInitInfo() { async function getInitInfo() {
const response = await api.get(`/api/global/auth/init`) const response = await api.get(`/api/global/auth/init`)
const json = response.json() const json = response.json()
@ -99,11 +108,7 @@ export function createAuthStore() {
return json return json
} }
return { const actions = {
subscribe: store.subscribe,
setOrganisation,
getInitInfo,
setInitInfo,
checkQueryString: async () => { checkQueryString: async () => {
const urlParams = new URLSearchParams(window.location.search) const urlParams = new URLSearchParams(window.location.search)
if (urlParams.has("tenantId")) { if (urlParams.has("tenantId")) {
@ -114,7 +119,7 @@ export function createAuthStore() {
setOrg: async tenantId => { setOrg: async tenantId => {
await setOrganisation(tenantId) await setOrganisation(tenantId)
}, },
checkAuth: async () => { getSelf: async () => {
const response = await api.get("/api/global/users/self") const response = await api.get("/api/global/users/self")
if (response.status !== 200) { if (response.status !== 200) {
setUser(null) setUser(null)
@ -129,13 +134,12 @@ export function createAuthStore() {
`/api/global/auth/${tenantId}/login`, `/api/global/auth/${tenantId}/login`,
creds creds
) )
const json = await response.json()
if (response.status === 200) { if (response.status === 200) {
setUser(json.user) await actions.getSelf()
} else { } else {
const json = await response.json()
throw new Error(json.message ? json.message : "Invalid credentials") throw new Error(json.message ? json.message : "Invalid credentials")
} }
return json
}, },
logout: async () => { logout: async () => {
const response = await api.post(`/api/global/auth/logout`) const response = await api.post(`/api/global/auth/logout`)
@ -145,6 +149,7 @@ export function createAuthStore() {
await response.json() await response.json()
await setInitInfo({}) await setInitInfo({})
setUser(null) setUser(null)
setPostLogout()
}, },
updateSelf: async fields => { updateSelf: async fields => {
const newUser = { ...get(auth).user, ...fields } const newUser = { ...get(auth).user, ...fields }
@ -187,6 +192,14 @@ export function createAuthStore() {
await response.json() await response.json()
}, },
} }
return {
subscribe: store.subscribe,
setOrganisation,
getInitInfo,
setInitInfo,
...actions,
}
} }
export const auth = createAuthStore() export const auth = createAuthStore()

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/cli", "name": "@budibase/cli",
"version": "1.0.43", "version": "1.0.49-alpha.5",
"description": "Budibase CLI, for developers, self hosting and migrations.", "description": "Budibase CLI, for developers, self hosting and migrations.",
"main": "src/index.js", "main": "src/index.js",
"bin": { "bin": {

View File

@ -2065,6 +2065,26 @@
} }
] ]
}, },
{
"type": "select",
"label": "Direction",
"key": "direction",
"defaultValue": "vertical",
"options": [
{
"label": "Horizontal",
"value": "horizontal"
},
{
"label": "Vertical",
"value": "vertical"
}
],
"dependsOn": {
"setting": "optionsType",
"value": "radio"
}
},
{ {
"type": "text", "type": "text",
"label": "Default value", "label": "Default value",
@ -2236,7 +2256,7 @@
"setting": "optionsSource", "setting": "optionsSource",
"value": "provider" "value": "provider"
} }
}, },
{ {
"type": "options", "type": "options",
"key": "customOptions", "key": "customOptions",
@ -2419,6 +2439,11 @@
"label": "Label", "label": "Label",
"key": "label" "key": "label"
}, },
{
"type": "text",
"label": "Extensions",
"key": "extensions"
},
{ {
"type": "boolean", "type": "boolean",
"label": "Disabled", "label": "Disabled",
@ -2811,7 +2836,7 @@
"key": "dataSource" "key": "dataSource"
}, },
{ {
"type": "multifield", "type": "searchfield",
"label": "Search Columns", "label": "Search Columns",
"key": "searchColumns", "key": "searchColumns",
"placeholder": "Choose search columns" "placeholder": "Choose search columns"
@ -2958,7 +2983,7 @@
"key": "dataSource" "key": "dataSource"
}, },
{ {
"type": "multifield", "type": "searchfield",
"label": "Search Columns", "label": "Search Columns",
"key": "searchColumns", "key": "searchColumns",
"placeholder": "Choose search columns" "placeholder": "Choose search columns"
@ -3315,5 +3340,50 @@
"suffix": "repeater" "suffix": "repeater"
} }
] ]
},
"s3upload": {
"name": "S3 File Upload",
"info": "This component can't be used with S3 datasources that use custom endpoints.",
"icon": "UploadToCloud",
"styles": ["size"],
"editable": true,
"settings": [
{
"type": "field/attachment",
"label": "Field",
"key": "field"
},
{
"type": "text",
"label": "Label",
"key": "label"
},
{
"type": "dataSource/s3",
"label": "S3 Datasource",
"key": "datasourceId"
},
{
"type": "text",
"label": "Bucket",
"key": "bucket"
},
{
"type": "text",
"label": "File Name",
"key": "key"
},
{
"type": "boolean",
"label": "Disabled",
"key": "disabled",
"defaultValue": false
},
{
"type": "validation/attachment",
"label": "Validation",
"key": "validation"
}
]
} }
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/client", "name": "@budibase/client",
"version": "1.0.43", "version": "1.0.49-alpha.5",
"license": "MPL-2.0", "license": "MPL-2.0",
"module": "dist/budibase-client.js", "module": "dist/budibase-client.js",
"main": "dist/budibase-client.js", "main": "dist/budibase-client.js",
@ -19,10 +19,11 @@
"dev:builder": "rollup -cw" "dev:builder": "rollup -cw"
}, },
"dependencies": { "dependencies": {
"@budibase/bbui": "^1.0.43", "@budibase/bbui": "^1.0.49-alpha.5",
"@budibase/standard-components": "^0.9.139", "@budibase/standard-components": "^0.9.139",
"@budibase/string-templates": "^1.0.43", "@budibase/string-templates": "^1.0.49-alpha.5",
"regexparam": "^1.3.0", "regexparam": "^1.3.0",
"rollup-plugin-polyfill-node": "^0.8.0",
"shortid": "^2.2.15", "shortid": "^2.2.15",
"svelte-spa-router": "^3.0.5" "svelte-spa-router": "^3.0.5"
}, },
@ -45,8 +46,6 @@
"postcss": "^8.2.10", "postcss": "^8.2.10",
"rollup": "^2.44.0", "rollup": "^2.44.0",
"rollup-plugin-json": "^4.0.0", "rollup-plugin-json": "^4.0.0",
"rollup-plugin-node-builtins": "^2.1.2",
"rollup-plugin-node-globals": "^1.4.0",
"rollup-plugin-postcss": "^4.0.0", "rollup-plugin-postcss": "^4.0.0",
"rollup-plugin-svelte": "^7.1.0", "rollup-plugin-svelte": "^7.1.0",
"rollup-plugin-svg": "^2.0.0", "rollup-plugin-svg": "^2.0.0",

View File

@ -6,8 +6,7 @@ import { terser } from "rollup-plugin-terser"
import postcss from "rollup-plugin-postcss" import postcss from "rollup-plugin-postcss"
import svg from "rollup-plugin-svg" import svg from "rollup-plugin-svg"
import json from "rollup-plugin-json" import json from "rollup-plugin-json"
import builtins from "rollup-plugin-node-builtins" import nodePolyfills from "rollup-plugin-polyfill-node"
import globals from "rollup-plugin-node-globals"
import path from "path" import path from "path"
const production = !process.env.ROLLUP_WATCH const production = !process.env.ROLLUP_WATCH
@ -75,8 +74,7 @@ export default {
}), }),
postcss(), postcss(),
commonjs(), commonjs(),
globals(), nodePolyfills(),
builtins(),
resolve({ resolve({
preferBuiltins: true, preferBuiltins: true,
browser: true, browser: true,

View File

@ -1,4 +1,5 @@
import { notificationStore } from "stores" import { notificationStore, authStore } from "stores"
import { get } from "svelte/store"
import { ApiVersion } from "constants" import { ApiVersion } from "constants"
/** /**
@ -28,6 +29,13 @@ const makeApiCall = async ({ method, url, body, json = true }) => {
...(json && { "Content-Type": "application/json" }), ...(json && { "Content-Type": "application/json" }),
...(!inBuilder && { "x-budibase-type": "client" }), ...(!inBuilder && { "x-budibase-type": "client" }),
} }
// add csrf token if authenticated
const auth = get(authStore)
if (auth && auth.csrfToken) {
headers["x-csrf-token"] = auth.csrfToken
}
const response = await fetch(url, { const response = await fetch(url, {
method, method,
headers, headers,
@ -36,7 +44,11 @@ const makeApiCall = async ({ method, url, body, json = true }) => {
}) })
switch (response.status) { switch (response.status) {
case 200: case 200:
return response.json() try {
return await response.json()
} catch (error) {
return null
}
case 401: case 401:
notificationStore.actions.error("Invalid credentials") notificationStore.actions.error("Invalid credentials")
return handleError(`Invalid credentials`) return handleError(`Invalid credentials`)
@ -82,14 +94,15 @@ const makeCachedApiCall = async params => {
* Constructs an API call function for a particular HTTP method. * Constructs an API call function for a particular HTTP method.
*/ */
const requestApiCall = method => async params => { const requestApiCall = method => async params => {
const { url, cache = false } = params const { external = false, url, cache = false } = params
const fixedUrl = `/${url}`.replace("//", "/") const fixedUrl = external ? url : `/${url}`.replace("//", "/")
const enrichedParams = { ...params, method, url: fixedUrl } const enrichedParams = { ...params, method, url: fixedUrl }
return await (cache ? makeCachedApiCall : makeApiCall)(enrichedParams) return await (cache ? makeCachedApiCall : makeApiCall)(enrichedParams)
} }
export default { export default {
post: requestApiCall("POST"), post: requestApiCall("POST"),
put: requestApiCall("PUT"),
get: requestApiCall("GET"), get: requestApiCall("GET"),
patch: requestApiCall("PATCH"), patch: requestApiCall("PATCH"),
del: requestApiCall("DELETE"), del: requestApiCall("DELETE"),

View File

@ -10,3 +10,41 @@ export const uploadAttachment = async (data, tableId = "") => {
json: false, json: false,
}) })
} }
/**
* Generates a signed URL to upload a file to an external datasource.
*/
export const getSignedDatasourceURL = async (datasourceId, bucket, key) => {
if (!datasourceId) {
return null
}
const res = await API.post({
url: `/api/attachments/${datasourceId}/url`,
body: { bucket, key },
})
if (res.error) {
throw "Could not generate signed upload URL"
}
return res
}
/**
* Uploads a file to an external datasource.
*/
export const externalUpload = async (datasourceId, bucket, key, data) => {
const { signedUrl, publicUrl } = await getSignedDatasourceURL(
datasourceId,
bucket,
key
)
const res = await API.put({
url: signedUrl,
body: data,
json: false,
external: true,
})
if (res?.error) {
throw "Could not upload file to signed URL"
}
return { publicUrl }
}

View File

@ -18,6 +18,15 @@ export const logIn = async ({ email, password }) => {
}) })
} }
/**
* Logs the user out and invaidates their session.
*/
export const logOut = async () => {
return await API.post({
url: "/api/global/auth/logout",
})
}
/** /**
* Fetches the currently logged in user object * Fetches the currently logged in user object
*/ */

View File

@ -63,8 +63,9 @@
} else { } else {
// The user is not logged in, redirect them to login // The user is not logged in, redirect them to login
const returnUrl = `${window.location.pathname}${window.location.hash}` const returnUrl = `${window.location.pathname}${window.location.hash}`
const encodedUrl = encodeURIComponent(returnUrl) // TODO: reuse `Cookies` from builder when frontend-core is added
window.location = `/builder/auth/login?returnUrl=${encodedUrl}` window.document.cookie = `budibase:returnurl=${returnUrl}; Path=/`
window.location = `/builder/auth/login`
} }
} }
} }

View File

@ -276,27 +276,29 @@
// reactive statements as much as possible. // reactive statements as much as possible.
const cacheSettings = (enriched, nested, conditional) => { const cacheSettings = (enriched, nested, conditional) => {
const allSettings = { ...enriched, ...nested, ...conditional } const allSettings = { ...enriched, ...nested, ...conditional }
if (!cachedSettings) { const mounted = ref?.$$set != null
if (!cachedSettings || !mounted) {
cachedSettings = { ...allSettings } cachedSettings = { ...allSettings }
initialSettings = cachedSettings initialSettings = cachedSettings
} else { } else {
Object.keys(allSettings).forEach(key => { Object.keys(allSettings).forEach(key => {
const same = propsAreSame(allSettings[key], cachedSettings[key]) const same = propsAreSame(allSettings[key], cachedSettings[key])
if (!same) { if (!same) {
// Updated cachedSettings (which is assigned by reference to
// initialSettings) so that if we remount the component then the
// initial props are up to date. By setting it this way rather than
// setting it on initialSettings directly, we avoid a double render.
cachedSettings[key] = allSettings[key] cachedSettings[key] = allSettings[key]
assignSetting(key, allSettings[key])
// Programmatically set the prop to avoid svelte reactive statements
// firing inside components. This circumvents the problems caused by
// spreading a props object.
ref.$$set({ [key]: allSettings[key] })
} }
}) })
} }
} }
// Assigns a certain setting to this component.
// We manually use the svelte $set function to avoid triggering additional
// reactive statements.
const assignSetting = (key, value) => {
ref?.$$set?.({ [key]: value })
}
// Generates a key used to determine when components need to fully remount. // Generates a key used to determine when components need to fully remount.
// Currently only toggling editing requires remounting. // Currently only toggling editing requires remounting.
const getRenderKey = (id, editing) => { const getRenderKey = (id, editing) => {
@ -305,7 +307,7 @@
</script> </script>
{#key renderKey} {#key renderKey}
{#if constructor && cachedSettings && (visible || inSelectedPath)} {#if constructor && initialSettings && (visible || inSelectedPath)}
<!-- The ID is used as a class because getElementsByClassName is O(1) --> <!-- The ID is used as a class because getElementsByClassName is O(1) -->
<!-- and the performance matters for the selection indicators --> <!-- and the performance matters for the selection indicators -->
<div <div

View File

@ -67,6 +67,7 @@
$: dataContext = { $: dataContext = {
rows: $fetch.rows, rows: $fetch.rows,
info: $fetch.info, info: $fetch.info,
datasource: dataSource || {},
schema: $fetch.schema, schema: $fetch.schema,
rowsLength: $fetch.rows.length, rowsLength: $fetch.rows.length,

View File

@ -39,6 +39,7 @@
number: "numberfield", number: "numberfield",
datetime: "datetimefield", datetime: "datetimefield",
boolean: "booleanfield", boolean: "booleanfield",
formula: "stringfield",
} }
let formId let formId
@ -71,12 +72,13 @@
const enrichFilter = (filter, columns, formId) => { const enrichFilter = (filter, columns, formId) => {
let enrichedFilter = [...(filter || [])] let enrichedFilter = [...(filter || [])]
columns?.forEach(column => { columns?.forEach(column => {
const safePath = column.name.split(".").map(safe).join(".")
enrichedFilter.push({ enrichedFilter.push({
field: column.name, field: column.name,
operator: column.type === "string" ? "string" : "equal", operator: column.type === "string" ? "string" : "equal",
type: column.type === "string" ? "string" : "number", type: column.type === "string" ? "string" : "number",
valueType: "Binding", valueType: "Binding",
value: `{{ [${formId}].[${column.name}] }}`, value: `{{ ${safe(formId)}.${safePath} }}`,
}) })
}) })
return enrichedFilter return enrichedFilter
@ -112,7 +114,9 @@
// Load the datasource schema so we can determine column types // Load the datasource schema so we can determine column types
const fetchSchema = async dataSource => { const fetchSchema = async dataSource => {
if (dataSource) { if (dataSource) {
schema = await fetchDatasourceSchema(dataSource) schema = await fetchDatasourceSchema(dataSource, {
enrichRelationships: true,
})
} }
schemaLoaded = true schemaLoaded = true
} }

View File

@ -35,6 +35,7 @@
number: "numberfield", number: "numberfield",
datetime: "datetimefield", datetime: "datetimefield",
boolean: "booleanfield", boolean: "booleanfield",
formula: "stringfield",
} }
let formId let formId
@ -59,12 +60,14 @@
const enrichFilter = (filter, columns, formId) => { const enrichFilter = (filter, columns, formId) => {
let enrichedFilter = [...(filter || [])] let enrichedFilter = [...(filter || [])]
columns?.forEach(column => { columns?.forEach(column => {
const safePath = column.name.split(".").map(safe).join(".")
const stringType = column.type === "string" || column.type === "formula"
enrichedFilter.push({ enrichedFilter.push({
field: column.name, field: column.name,
operator: column.type === "string" ? "string" : "equal", operator: stringType ? "string" : "equal",
type: column.type === "string" ? "string" : "number", type: stringType ? "string" : "number",
valueType: "Binding", valueType: "Binding",
value: `{{ ${safe(formId)}.${safe(column.name)} }}`, value: `{{ ${safe(formId)}.${safePath} }}`,
}) })
}) })
return enrichedFilter return enrichedFilter
@ -90,7 +93,9 @@
// Load the datasource schema so we can determine column types // Load the datasource schema so we can determine column types
const fetchSchema = async dataSource => { const fetchSchema = async dataSource => {
if (dataSource) { if (dataSource) {
schema = await fetchDatasourceSchema(dataSource) schema = await fetchDatasourceSchema(dataSource, {
enrichRelationships: true,
})
} }
schemaLoaded = true schemaLoaded = true
} }

Some files were not shown because too many files have changed in this diff Show More