1
0
Fork 0
mirror of synced 2024-06-28 02:50:50 +12:00

Merge pull request #7505 from Budibase/develop

Develop -> Master
This commit is contained in:
Martin McKeaveney 2022-09-02 10:42:46 +01:00 committed by GitHub
commit bb1bc8f49a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
230 changed files with 5951 additions and 3225 deletions

View file

@ -162,6 +162,7 @@
"translation"
]
},
{
"login": "mslourens",
"name": "Maurits Lourens",
"avatar_url": "https://avatars.githubusercontent.com/u/1907152?v=4",

View file

@ -69,6 +69,28 @@ jobs:
env:
KUBECONFIG_FILE: '${{ secrets.RELEASE_KUBECONFIG }}'
- name: Re roll app-service
uses: actions-hub/kubectl@master
env:
KUBE_CONFIG: ${{ secrets.RELEASE_KUBECONFIG_BASE64 }}
with:
args: rollout restart deployment app-service -n budibase
- name: Re roll proxy-service
uses: actions-hub/kubectl@master
env:
KUBE_CONFIG: ${{ secrets.RELEASE_KUBECONFIG_BASE64 }}
with:
args: rollout restart deployment proxy-service -n budibase
- name: Re roll worker-service
uses: actions-hub/kubectl@master
env:
KUBE_CONFIG: ${{ secrets.RELEASE_KUBECONFIG_BASE64 }}
with:
args: rollout restart deployment worker-service -n budibase
- name: Discord Webhook Action
uses: tsickert/discord-webhook@v4.0.0
with:

View file

@ -18,8 +18,9 @@ on:
workflow_dispatch:
env:
# Posthog token used by ui at build time
POSTHOG_TOKEN: phc_uDYOfnFt6wAbBAXkC6STjcrTpAFiWIhqgFcsC1UVO5F
# Posthog token used by ui at build time
# disable unless needed for testing
# POSTHOG_TOKEN: phc_uDYOfnFt6wAbBAXkC6STjcrTpAFiWIhqgFcsC1UVO5F
INTERCOM_TOKEN: ${{ secrets.INTERCOM_TOKEN }}
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
FEATURE_PREVIEW_URL: https://budirelease.live
@ -119,6 +120,27 @@ jobs:
]
env:
KUBECONFIG_FILE: '${{ secrets.RELEASE_KUBECONFIG }}'
- name: Re roll app-service
uses: actions-hub/kubectl@master
env:
KUBE_CONFIG: ${{ secrets.RELEASE_KUBECONFIG_BASE64 }}
with:
args: rollout restart deployment app-service -n budibase
- name: Re roll proxy-service
uses: actions-hub/kubectl@master
env:
KUBE_CONFIG: ${{ secrets.RELEASE_KUBECONFIG_BASE64 }}
with:
args: rollout restart deployment proxy-service -n budibase
- name: Re roll worker-service
uses: actions-hub/kubectl@master
env:
KUBE_CONFIG: ${{ secrets.RELEASE_KUBECONFIG_BASE64 }}
with:
args: rollout restart deployment worker-service -n budibase
- name: Discord Webhook Action
uses: tsickert/discord-webhook@v4.0.0

View file

@ -1,4 +1,4 @@
name: Budibase Smoke Test
name: Budibase Nightly Tests
on:
workflow_dispatch:
@ -6,7 +6,7 @@ on:
- cron: "0 5 * * *" # every day at 5AM
jobs:
release:
nightly:
runs-on: ubuntu-latest
steps:
@ -43,6 +43,18 @@ jobs:
name: Test Reports
path: packages/builder/cypress/reports/testReport.html
# TODO: enable once running in QA test env
# - name: Configure AWS Credentials
# uses: aws-actions/configure-aws-credentials@v1
# with:
# aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
# aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
# aws-region: eu-west-1
# - name: Upload test results HTML
# uses: aws-actions/configure-aws-credentials@v1
# run: aws s3 cp packages/builder/cypress/reports/testReport.html s3://{{ secrets.BUDI_QA_REPORTS_BUCKET_NAME }}/$GITHUB_RUN_ID/index.html
- name: Cypress Discord Notify
run: yarn test:e2e:ci:notify
env:

View file

@ -4,7 +4,7 @@
"singleQuote": false,
"trailingComma": "es5",
"arrowParens": "avoid",
"jsxBracketSameLine": false,
"bracketSameLine": false,
"plugins": ["prettier-plugin-svelte"],
"svelteSortOrder": "options-scripts-markup-styles"
}

View file

@ -65,6 +65,10 @@ http {
proxy_pass http://{{ address }}:4001;
}
location /preview {
proxy_pass http://{{ address }}:4001;
}
location /builder {
proxy_pass http://{{ address }}:3000;
rewrite ^/builder(.*)$ /builder/$1 break;

View file

@ -88,6 +88,10 @@ http {
proxy_pass http://$apps:4002;
}
location /preview {
proxy_pass http://$apps:4002;
}
location = / {
proxy_pass http://$apps:4002;
}
@ -97,6 +101,7 @@ http {
proxy_pass http://$watchtower:8080;
}
{{/if}}
location ~ ^/(builder|app_) {
proxy_http_version 1.1;
proxy_set_header Connection $connection_upgrade;

View file

@ -3,15 +3,18 @@
echo ${TARGETBUILD} > /buildtarget.txt
if [[ "${TARGETBUILD}" = "aas" ]]; then
# Azure AppService uses /home for persisent data & SSH on port 2222
mkdir -p /home/{search,minio,couch}
mkdir -p /home/couch/{dbs,views}
chown -R couchdb:couchdb /home/couch/
DATA_DIR=/home
mkdir -p $DATA_DIR/{search,minio,couchdb}
mkdir -p $DATA_DIR/couchdb/{dbs,views}
chown -R couchdb:couchdb $DATA_DIR/couchdb/
apt update
apt-get install -y openssh-server
sed -i 's#dir=/opt/couchdb/data/search#dir=/home/search#' /opt/clouseau/clouseau.ini
sed -i 's#/minio/minio server /minio &#/minio/minio server /home/minio &#' /runner.sh
sed -i 's#database_dir = ./data#database_dir = /home/couch/dbs#' /opt/couchdb/etc/default.ini
sed -i 's#view_index_dir = ./data#view_index_dir = /home/couch/views#' /opt/couchdb/etc/default.ini
sed -i "s/#Port 22/Port 2222/" /etc/ssh/sshd_config
/etc/init.d/ssh restart
fi
sed -i "s#DATA_DIR#/home#g" /opt/clouseau/clouseau.ini
sed -i "s#DATA_DIR#/home#g" /opt/couchdb/etc/local.ini
else
sed -i "s#DATA_DIR#/data#g" /opt/clouseau/clouseau.ini
sed -i "s#DATA_DIR#/data#g" /opt/couchdb/etc/local.ini
fi

View file

@ -20,10 +20,10 @@ RUN node /pinVersions.js && yarn && yarn build && /cleanup.sh
FROM couchdb:3.2.1
# TARGETARCH can be amd64 or arm e.g. docker build --build-arg TARGETARCH=amd64
ARG TARGETARCH amd64
ARG TARGETARCH=amd64
#TARGETBUILD can be set to single (for single docker image) or aas (for azure app service)
# e.g. docker build --build-arg TARGETBUILD=aas ....
ARG TARGETBUILD single
ARG TARGETBUILD=single
ENV TARGETBUILD $TARGETBUILD
COPY --from=build /app /app
@ -35,6 +35,7 @@ ENV \
BUDIBASE_ENVIRONMENT=PRODUCTION \
CLUSTER_PORT=80 \
# CUSTOM_DOMAIN=budi001.custom.com \
DATA_DIR=/data \
DEPLOYMENT_ENVIRONMENT=docker \
MINIO_URL=http://localhost:9000 \
POSTHOG_TOKEN=phc_bIjZL7oh2GEUd2vqvTBH8WvrX0fWTFQMs6H5KQxiUxU \
@ -114,6 +115,7 @@ RUN chmod +x ./healthcheck.sh
ADD hosting/scripts/build-target-paths.sh .
RUN chmod +x ./build-target-paths.sh
# Script below sets the path for storing data based on $DATA_DIR
# For Azure App Service install SSH & point data locations to /home
RUN /build-target-paths.sh

View file

@ -7,7 +7,7 @@ name=clouseau@127.0.0.1
cookie=monster
; the path where you would like to store the search index files
dir=/data/search
dir=DATA_DIR/search
; the number of search indexes that can be open simultaneously
max_indexes_open=500

View file

@ -1,5 +1,5 @@
; CouchDB Configuration Settings
[couchdb]
database_dir = /data/couch/dbs
view_index_dir = /data/couch/views
database_dir = DATA_DIR/couchdb/dbs
view_index_dir = DATA_DIR/couchdb/views

View file

@ -3,6 +3,11 @@ healthy=true
if [ -f "/data/.env" ]; then
export $(cat /data/.env | xargs)
elif [ -f "/home/.env" ]; then
export $(cat /home/.env | xargs)
else
echo "No .env file found"
healthy=false
fi
if [[ $(curl -Lfk -s -w "%{http_code}\n" http://localhost/ -o /dev/null) -ne 200 ]]; then

View file

@ -1,7 +1,16 @@
#!/bin/bash
declare -a ENV_VARS=("COUCHDB_USER" "COUCHDB_PASSWORD" "MINIO_ACCESS_KEY" "MINIO_SECRET_KEY" "INTERNAL_API_KEY" "JWT_SECRET" "REDIS_PASSWORD")
if [ -f "/data/.env" ]; then
export $(cat /data/.env | xargs)
declare -a ENV_VARS=("COUCHDB_USER" "COUCHDB_PASSWORD" "DATA_DIR" "MINIO_ACCESS_KEY" "MINIO_SECRET_KEY" "INTERNAL_API_KEY" "JWT_SECRET" "REDIS_PASSWORD")
# Azure App Service customisations
if [[ "${TARGETBUILD}" = "aas" ]]; then
DATA_DIR=/home
/etc/init.d/ssh start
else
DATA_DIR=${DATA_DIR:-/data}
fi
if [ -f "${DATA_DIR}/.env" ]; then
export $(cat ${DATA_DIR}/.env | xargs)
fi
# first randomise any unset environment variables
for ENV_VAR in "${ENV_VARS[@]}"
@ -14,21 +23,26 @@ done
if [[ -z "${COUCH_DB_URL}" ]]; then
export COUCH_DB_URL=http://$COUCHDB_USER:$COUCHDB_PASSWORD@localhost:5984
fi
if [ ! -f "/data/.env" ]; then
touch /data/.env
if [ ! -f "${DATA_DIR}/.env" ]; then
touch ${DATA_DIR}/.env
for ENV_VAR in "${ENV_VARS[@]}"
do
temp=$(eval "echo \$$ENV_VAR")
echo "$ENV_VAR=$temp" >> /data/.env
echo "$ENV_VAR=$temp" >> ${DATA_DIR}/.env
done
echo "COUCH_DB_URL=${COUCH_DB_URL}" >> ${DATA_DIR}/.env
fi
export COUCH_DB_URL=http://$COUCHDB_USER:$COUCHDB_PASSWORD@localhost:5984
# make these directories in runner, incase of mount
mkdir -p /data/couch/{dbs,views} /home/couch/{dbs,views}
chown -R couchdb:couchdb /data/couch /home/couch
mkdir -p ${DATA_DIR}/couchdb/{dbs,views}
mkdir -p ${DATA_DIR}/minio
mkdir -p ${DATA_DIR}/search
chown -R couchdb:couchdb ${DATA_DIR}/couchdb
redis-server --requirepass $REDIS_PASSWORD &
/opt/clouseau/bin/clouseau &
/minio/minio server /data/minio &
/minio/minio server ${DATA_DIR}/minio &
/docker-entrypoint.sh /opt/couchdb/bin/couchdb &
/etc/init.d/nginx restart
if [[ ! -z "${CUSTOM_DOMAIN}" ]]; then

View file

@ -1,5 +1,5 @@
{
"version": "1.2.58",
"version": "1.2.59-alpha.0",
"npmClient": "yarn",
"packages": [
"packages/*"

View file

@ -1,6 +1,6 @@
{
"name": "@budibase/backend-core",
"version": "1.2.58",
"version": "1.2.59-alpha.0",
"description": "Budibase backend core libraries used in server and worker",
"main": "dist/src/index.js",
"types": "dist/src/index.d.ts",
@ -20,13 +20,14 @@
"test:watch": "jest --watchAll"
},
"dependencies": {
"@budibase/types": "^1.2.58",
"@budibase/types": "1.2.59-alpha.0",
"@techpass/passport-openidconnect": "0.3.2",
"aws-sdk": "2.1030.0",
"bcrypt": "5.0.1",
"dotenv": "16.0.1",
"emitter-listener": "1.1.2",
"ioredis": "4.28.0",
"joi": "17.6.0",
"jsonwebtoken": "8.5.1",
"koa-passport": "4.1.4",
"lodash": "4.17.21",

View file

@ -1,11 +1,11 @@
const passport = require("koa-passport")
const LocalStrategy = require("passport-local").Strategy
const JwtStrategy = require("passport-jwt").Strategy
const { getGlobalDB } = require("./tenancy")
import { getGlobalDB } from "./tenancy"
const refresh = require("passport-oauth2-refresh")
const { Configs } = require("./constants")
const { getScopedConfig } = require("./db/utils")
const {
import { Configs } from "./constants"
import { getScopedConfig } from "./db/utils"
import {
jwt,
local,
authenticated,
@ -13,7 +13,6 @@ const {
oidc,
auditLog,
tenancy,
appTenancy,
authError,
ssoCallbackUrl,
csrf,
@ -22,32 +21,36 @@ const {
builderOnly,
builderOrAdmin,
joiValidator,
} = require("./middleware")
const { invalidateUser } = require("./cache/user")
} from "./middleware"
import { invalidateUser } from "./cache/user"
import { User } from "@budibase/types"
// Strategies
passport.use(new LocalStrategy(local.options, local.authenticate))
passport.use(new JwtStrategy(jwt.options, jwt.authenticate))
passport.serializeUser((user, done) => done(null, user))
passport.serializeUser((user: User, done: any) => done(null, user))
passport.deserializeUser(async (user, done) => {
passport.deserializeUser(async (user: User, done: any) => {
const db = getGlobalDB()
try {
const user = await db.get(user._id)
return done(null, user)
const dbUser = await db.get(user._id)
return done(null, dbUser)
} catch (err) {
console.error(`User not found`, err)
return done(null, false, { message: "User not found" })
}
})
async function refreshOIDCAccessToken(db, chosenConfig, refreshToken) {
async function refreshOIDCAccessToken(
db: any,
chosenConfig: any,
refreshToken: string
) {
const callbackUrl = await oidc.getCallbackUrl(db, chosenConfig)
let enrichedConfig
let strategy
let enrichedConfig: any
let strategy: any
try {
enrichedConfig = await oidc.fetchStrategyConfig(chosenConfig, callbackUrl)
@ -70,22 +73,28 @@ async function refreshOIDCAccessToken(db, chosenConfig, refreshToken) {
refresh.requestNewAccessToken(
Configs.OIDC,
refreshToken,
(err, accessToken, refreshToken, params) => {
(err: any, accessToken: string, refreshToken: any, params: any) => {
resolve({ err, accessToken, refreshToken, params })
}
)
})
}
async function refreshGoogleAccessToken(db, config, refreshToken) {
async function refreshGoogleAccessToken(
db: any,
config: any,
refreshToken: any
) {
let callbackUrl = await google.getCallbackUrl(db, config)
let strategy
try {
strategy = await google.strategyFactory(config, callbackUrl)
} catch (err) {
} catch (err: any) {
console.error(err)
throw new Error("Error constructing OIDC refresh strategy", err)
throw new Error(
`Error constructing OIDC refresh strategy: message=${err.message}`
)
}
refresh.use(strategy)
@ -94,14 +103,18 @@ async function refreshGoogleAccessToken(db, config, refreshToken) {
refresh.requestNewAccessToken(
Configs.GOOGLE,
refreshToken,
(err, accessToken, refreshToken, params) => {
(err: any, accessToken: string, refreshToken: string, params: any) => {
resolve({ err, accessToken, refreshToken, params })
}
)
})
}
async function refreshOAuthToken(refreshToken, configType, configId) {
async function refreshOAuthToken(
refreshToken: string,
configType: string,
configId: string
) {
const db = getGlobalDB()
const config = await getScopedConfig(db, {
@ -113,7 +126,7 @@ async function refreshOAuthToken(refreshToken, configType, configId) {
let refreshResponse
if (configType === Configs.OIDC) {
// configId - retrieved from cookie.
chosenConfig = config.configs.filter(c => c.uuid === configId)[0]
chosenConfig = config.configs.filter((c: any) => c.uuid === configId)[0]
if (!chosenConfig) {
throw new Error("Invalid OIDC configuration")
}
@ -134,7 +147,7 @@ async function refreshOAuthToken(refreshToken, configType, configId) {
return refreshResponse
}
async function updateUserOAuth(userId, oAuthConfig) {
async function updateUserOAuth(userId: string, oAuthConfig: any) {
const details = {
accessToken: oAuthConfig.accessToken,
refreshToken: oAuthConfig.refreshToken,
@ -162,14 +175,13 @@ async function updateUserOAuth(userId, oAuthConfig) {
}
}
module.exports = {
export = {
buildAuthMiddleware: authenticated,
passport,
google,
oidc,
jwt: require("jsonwebtoken"),
buildTenancyMiddleware: tenancy,
buildAppTenancyMiddleware: appTenancy,
auditLog,
authError,
buildCsrfMiddleware: csrf,

View file

@ -18,6 +18,7 @@ export enum ViewName {
LINK = "by_link",
ROUTING = "screen_routes",
AUTOMATION_LOGS = "automation_logs",
ACCOUNT_BY_EMAIL = "account_by_email",
}
export const DeprecatedViews = {
@ -41,6 +42,7 @@ export enum DocumentType {
MIGRATIONS = "migrations",
DEV_INFO = "devinfo",
AUTOMATION_LOG = "log_au",
ACCOUNT_METADATA = "acc_metadata",
}
export const StaticDatabases = {

View file

@ -233,6 +233,10 @@ export async function getAllApps({ dev, all, idsOnly, efficient }: any = {}) {
}
let dbs = await getAllDbs({ efficient })
const appDbNames = dbs.filter((dbName: any) => {
if (env.isTest() && !dbName) {
return false
}
const split = dbName.split(SEPARATOR)
// it is an app, check the tenantId
if (split[0] === DocumentType.APP) {

View file

@ -5,6 +5,8 @@ const {
SEPARATOR,
} = require("./utils")
const { getGlobalDB } = require("../tenancy")
const { StaticDatabases } = require("./constants")
const { doWithDB } = require("./")
const DESIGN_DB = "_design/database"
@ -56,6 +58,31 @@ exports.createNewUserEmailView = async () => {
await db.put(designDoc)
}
exports.createAccountEmailView = async () => {
await doWithDB(StaticDatabases.PLATFORM_INFO.name, async db => {
let designDoc
try {
designDoc = await db.get(DESIGN_DB)
} catch (err) {
// no design doc, make one
designDoc = DesignDoc()
}
const view = {
// if using variables in a map function need to inject them before use
map: `function(doc) {
if (doc._id.startsWith("${DocumentType.ACCOUNT_METADATA}${SEPARATOR}")) {
emit(doc.email.toLowerCase(), doc._id)
}
}`,
}
designDoc.views = {
...designDoc.views,
[ViewName.ACCOUNT_BY_EMAIL]: view,
}
await db.put(designDoc)
})
}
exports.createUserAppView = async () => {
const db = getGlobalDB()
let designDoc
@ -128,6 +155,39 @@ exports.createUserBuildersView = async () => {
await db.put(designDoc)
}
exports.queryView = async (viewName, params, db, CreateFuncByName) => {
try {
let response = (await db.query(`database/${viewName}`, params)).rows
response = response.map(resp =>
params.include_docs ? resp.doc : resp.value
)
if (params.arrayResponse) {
return response
} else {
return response.length <= 1 ? response[0] : response
}
} catch (err) {
if (err != null && err.name === "not_found") {
const createFunc = CreateFuncByName[viewName]
await removeDeprecated(db, viewName)
await createFunc()
return exports.queryView(viewName, params, db, CreateFuncByName)
} else {
throw err
}
}
}
exports.queryPlatformView = async (viewName, params) => {
const CreateFuncByName = {
[ViewName.ACCOUNT_BY_EMAIL]: exports.createAccountEmailView,
}
return doWithDB(StaticDatabases.PLATFORM_INFO.name, async db => {
return exports.queryView(viewName, params, db, CreateFuncByName)
})
}
exports.queryGlobalView = async (viewName, params, db = null) => {
const CreateFuncByName = {
[ViewName.USER_BY_EMAIL]: exports.createNewUserEmailView,
@ -139,20 +199,5 @@ exports.queryGlobalView = async (viewName, params, db = null) => {
if (!db) {
db = getGlobalDB()
}
try {
let response = (await db.query(`database/${viewName}`, params)).rows
response = response.map(resp =>
params.include_docs ? resp.doc : resp.value
)
return response.length <= 1 ? response[0] : response
} catch (err) {
if (err != null && err.name === "not_found") {
const createFunc = CreateFuncByName[viewName]
await removeDeprecated(db, viewName)
await createFunc()
return exports.queryGlobalView(viewName, params)
} else {
throw err
}
}
return exports.queryView(viewName, params, db, CreateFuncByName)
}

View file

@ -8,4 +8,5 @@ import { processors } from "./processors"
export const shutdown = () => {
processors.shutdown()
console.log("Events shutdown")
}

View file

@ -17,6 +17,7 @@ import constants from "./constants"
import * as dbConstants from "./db/constants"
import logging from "./logging"
import pino from "./pino"
import * as middleware from "./middleware"
// mimic the outer package exports
import * as db from "./pkg/db"
@ -57,6 +58,7 @@ const core = {
roles,
...pino,
...errorClasses,
middleware,
}
export = core

View file

@ -65,7 +65,7 @@ async function checkApiKey(apiKey: string, populateUser?: Function) {
* The tenancy modules should not be used here and it should be assumed that the tenancy context
* has not yet been populated.
*/
module.exports = (
export = (
noAuthPatterns = [],
opts: { publicAllowed: boolean; populateUser?: Function } = {
publicAllowed: false,

View file

@ -13,7 +13,8 @@ const adminOnly = require("./adminOnly")
const builderOrAdmin = require("./builderOrAdmin")
const builderOnly = require("./builderOnly")
const joiValidator = require("./joi-validator")
module.exports = {
const pkg = {
google,
oidc,
jwt,
@ -33,3 +34,5 @@ module.exports = {
builderOrAdmin,
joiValidator,
}
export = pkg

View file

@ -1,3 +1,5 @@
const Joi = require("joi")
function validate(schema, property) {
// Return a Koa middleware function
return (ctx, next) => {
@ -10,6 +12,15 @@ function validate(schema, property) {
} else if (ctx.request[property] != null) {
params = ctx.request[property]
}
// not all schemas have the append property e.g. array schemas
if (schema.append) {
schema = schema.append({
createdAt: Joi.any().optional(),
updatedAt: Joi.any().optional(),
})
}
const { error } = schema.validate(params)
if (error) {
ctx.throw(400, `Invalid ${property} - ${error.message}`)

View file

@ -3,17 +3,27 @@ const { v4: uuidv4 } = require("uuid")
const { logWarn } = require("../logging")
const env = require("../environment")
interface Session {
key: string
userId: string
interface CreateSession {
sessionId: string
lastAccessedAt: string
createdAt: string
tenantId: string
csrfToken?: string
value: string
}
type SessionKey = { key: string }[]
interface Session extends CreateSession {
userId: string
lastAccessedAt: string
createdAt: string
// make optional attributes required
csrfToken: string
}
interface SessionKey {
key: string
}
interface ScannedSession {
value: Session
}
// a week in seconds
const EXPIRY_SECONDS = 86400 * 7
@ -22,14 +32,14 @@ function makeSessionID(userId: string, sessionId: string) {
return `${userId}/${sessionId}`
}
export async function getSessionsForUser(userId: string) {
export async function getSessionsForUser(userId: string): Promise<Session[]> {
if (!userId) {
console.trace("Cannot get sessions for undefined userId")
return []
}
const client = await redis.getSessionClient()
const sessions = await client.scan(userId)
return sessions.map((session: Session) => session.value)
const sessions: ScannedSession[] = await client.scan(userId)
return sessions.map(session => session.value)
}
export async function invalidateSessions(
@ -39,33 +49,32 @@ export async function invalidateSessions(
try {
const reason = opts?.reason || "unknown"
let sessionIds: string[] = opts.sessionIds || []
let sessions: SessionKey
let sessionKeys: SessionKey[]
// If no sessionIds, get all the sessions for the user
if (sessionIds.length === 0) {
sessions = await getSessionsForUser(userId)
sessions.forEach(
(session: any) =>
(session.key = makeSessionID(session.userId, session.sessionId))
)
const sessions = await getSessionsForUser(userId)
sessionKeys = sessions.map(session => ({
key: makeSessionID(session.userId, session.sessionId),
}))
} else {
// use the passed array of sessionIds
sessionIds = Array.isArray(sessionIds) ? sessionIds : [sessionIds]
sessions = sessionIds.map((sessionId: string) => ({
sessionKeys = sessionIds.map(sessionId => ({
key: makeSessionID(userId, sessionId),
}))
}
if (sessions && sessions.length > 0) {
if (sessionKeys && sessionKeys.length > 0) {
const client = await redis.getSessionClient()
const promises = []
for (let session of sessions) {
promises.push(client.delete(session.key))
for (let sessionKey of sessionKeys) {
promises.push(client.delete(sessionKey.key))
}
if (!env.isTest()) {
logWarn(
`Invalidating sessions for ${userId} (reason: ${reason}) - ${sessions
.map(session => session.key)
`Invalidating sessions for ${userId} (reason: ${reason}) - ${sessionKeys
.map(sessionKey => sessionKey.key)
.join(", ")}`
)
}
@ -76,22 +85,26 @@ export async function invalidateSessions(
}
}
export async function createASession(userId: string, session: Session) {
export async function createASession(
userId: string,
createSession: CreateSession
) {
// invalidate all other sessions
await invalidateSessions(userId, { reason: "creation" })
const client = await redis.getSessionClient()
const sessionId = session.sessionId
if (!session.csrfToken) {
session.csrfToken = uuidv4()
}
session = {
...session,
const sessionId = createSession.sessionId
const csrfToken = createSession.csrfToken ? createSession.csrfToken : uuidv4()
const key = makeSessionID(userId, sessionId)
const session: Session = {
...createSession,
csrfToken,
createdAt: new Date().toISOString(),
lastAccessedAt: new Date().toISOString(),
userId,
}
await client.store(makeSessionID(userId, sessionId), session, EXPIRY_SECONDS)
await client.store(key, session, EXPIRY_SECONDS)
}
export async function updateSessionTTL(session: Session) {
@ -106,7 +119,10 @@ export async function endSession(userId: string, sessionId: string) {
await client.delete(makeSessionID(userId, sessionId))
}
export async function getSession(userId: string, sessionId: string) {
export async function getSession(
userId: string,
sessionId: string
): Promise<Session> {
if (!userId || !sessionId) {
throw new Error(`Invalid session details - ${userId} - ${sessionId}`)
}

View file

@ -11,7 +11,6 @@ const { UNICODE_MAX } = require("./db/constants")
* Given an email address this will use a view to search through
* all the users to find one with this email address.
* @param {string} email the email to lookup the user by.
* @return {Promise<object|null>}
*/
exports.getGlobalUserByEmail = async email => {
if (email == null) {

View file

@ -0,0 +1,7 @@
export const getAccount = jest.fn()
export const getAccountByTenantId = jest.fn()
jest.mock("../../../src/cloud/accounts", () => ({
getAccount,
getAccountByTenantId,
}))

View file

@ -1,2 +0,0 @@
exports.MOCK_DATE = new Date("2020-01-01T00:00:00.000Z")
exports.MOCK_DATE_TIMESTAMP = 1577836800000

View file

@ -0,0 +1,2 @@
export const MOCK_DATE = new Date("2020-01-01T00:00:00.000Z")
export const MOCK_DATE_TIMESTAMP = 1577836800000

View file

@ -1,9 +0,0 @@
const posthog = require("./posthog")
const events = require("./events")
const date = require("./date")
module.exports = {
posthog,
date,
events,
}

View file

@ -0,0 +1,4 @@
import "./posthog"
import "./events"
export * as accounts from "./accounts"
export * as date from "./date"

View file

@ -291,6 +291,18 @@
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
"@hapi/hoek@^9.0.0":
version "9.3.0"
resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.3.0.tgz#8368869dcb735be2e7f5cb7647de78e167a251fb"
integrity sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==
"@hapi/topo@^5.0.0":
version "5.1.0"
resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-5.1.0.tgz#dc448e332c6c6e37a4dc02fd84ba8d44b9afb012"
integrity sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==
dependencies:
"@hapi/hoek" "^9.0.0"
"@istanbuljs/load-nyc-config@^1.0.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced"
@ -539,6 +551,23 @@
koa "^2.13.4"
node-mocks-http "^1.5.8"
"@sideway/address@^4.1.3":
version "4.1.4"
resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.4.tgz#03dccebc6ea47fdc226f7d3d1ad512955d4783f0"
integrity sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==
dependencies:
"@hapi/hoek" "^9.0.0"
"@sideway/formula@^3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@sideway/formula/-/formula-3.0.0.tgz#fe158aee32e6bd5de85044be615bc08478a0a13c"
integrity sha512-vHe7wZ4NOXVfkoRb8T5otiENVlT7a3IAiw7H5M2+GO+9CDgcVUUsX1zalAztCmwyOr2RUTGJdgB+ZvSVqmdHmg==
"@sideway/pinpoint@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@sideway/pinpoint/-/pinpoint-2.0.0.tgz#cff8ffadc372ad29fd3f78277aeb29e632cc70df"
integrity sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==
"@sindresorhus/is@^0.14.0":
version "0.14.0"
resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea"
@ -3193,6 +3222,17 @@ jmespath@0.15.0:
resolved "https://registry.yarnpkg.com/jmespath/-/jmespath-0.15.0.tgz#a3f222a9aae9f966f5d27c796510e28091764217"
integrity sha512-+kHj8HXArPfpPEKGLZ+kB5ONRTCiGQXo8RQYL0hH8t6pWXUBBK5KkkQmTNOwKK4LEsd0yTsgtjJVm4UBSZea4w==
joi@17.6.0:
version "17.6.0"
resolved "https://registry.yarnpkg.com/joi/-/joi-17.6.0.tgz#0bb54f2f006c09a96e75ce687957bd04290054b2"
integrity sha512-OX5dG6DTbcr/kbMFj0KGYxuew69HPcAE3K/sZpEV2nP6e/j/C0HV+HNiBPCASxdx5T7DMoa0s8UeHWMnb6n2zw==
dependencies:
"@hapi/hoek" "^9.0.0"
"@hapi/topo" "^5.0.0"
"@sideway/address" "^4.1.3"
"@sideway/formula" "^3.0.0"
"@sideway/pinpoint" "^2.0.0"
join-component@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/join-component/-/join-component-1.1.0.tgz#b8417b750661a392bee2c2537c68b2a9d4977cd5"

View file

@ -1,7 +1,7 @@
{
"name": "@budibase/bbui",
"description": "A UI solution used in the different Budibase projects.",
"version": "1.2.58",
"version": "1.2.59-alpha.0",
"license": "MPL-2.0",
"svelte": "src/index.js",
"module": "dist/bbui.es.js",
@ -38,7 +38,7 @@
],
"dependencies": {
"@adobe/spectrum-css-workflow-icons": "^1.2.1",
"@budibase/string-templates": "^1.2.58",
"@budibase/string-templates": "1.2.59-alpha.0",
"@spectrum-css/actionbutton": "^1.0.1",
"@spectrum-css/actiongroup": "^1.0.1",
"@spectrum-css/avatar": "^3.0.2",

View file

@ -16,6 +16,7 @@
export let appendTo = undefined
export let timeOnly = false
export let ignoreTimezones = false
export let time24hr = false
const dispatch = createEventDispatcher()
const flatpickrId = `${uuid()}-wrapper`
@ -37,6 +38,7 @@
enableTime: timeOnly || enableTime || false,
noCalendar: timeOnly || false,
altInput: true,
time_24hr: time24hr || false,
altFormat: timeOnly ? "H:i" : enableTime ? "F j Y, H:i" : "F j, Y",
wrap: true,
appendTo,
@ -49,6 +51,12 @@
},
}
$: redrawOptions = {
timeOnly,
enableTime,
time24hr,
}
const handleChange = event => {
const [dates] = event.detail
const noTimezone = enableTime && !timeOnly && ignoreTimezones
@ -149,7 +157,7 @@
}
</script>
{#key timeOnly}
{#key redrawOptions}
<Flatpickr
bind:flatpickr
value={parseDate(value)}

View file

@ -17,6 +17,7 @@
export let disabled = false
export let fileSizeLimit = BYTES_IN_MB * 20
export let processFiles = null
export let deleteAttachments = null
export let handleFileTooLarge = null
export let handleTooManyFiles = null
export let gallery = true
@ -94,6 +95,11 @@
"change",
value.filter((x, idx) => idx !== selectedImageIdx)
)
if (deleteAttachments) {
await deleteAttachments(
value.filter((x, idx) => idx === selectedImageIdx).map(item => item.key)
)
}
selectedImageIdx = 0
}

View file

@ -23,7 +23,7 @@
$: toggleOption = makeToggleOption(selectedLookupMap, value)
const getFieldText = (value, map, placeholder) => {
if (value?.length) {
if (Array.isArray(value) && value.length > 0) {
if (!map) {
return ""
}
@ -36,7 +36,7 @@
const getSelectedLookupMap = value => {
let map = {}
if (value?.length) {
if (Array.isArray(value) && value.length > 0) {
value.forEach(option => {
if (option) {
map[option] = true

View file

@ -10,6 +10,7 @@
export let disabled = false
export let getOptionLabel = option => option
export let getOptionValue = option => option
export let getOptionTitle = option => option
const dispatch = createEventDispatcher()
const onChange = e => dispatch("change", e.target.value)
@ -19,7 +20,7 @@
{#if options && Array.isArray(options)}
{#each options as option}
<div
title={getOptionLabel(option)}
title={getOptionTitle(option)}
class="spectrum-Radio spectrum-FieldGroup-item spectrum-Radio--emphasized"
class:is-invalid={!!error}
>

View file

@ -10,6 +10,7 @@
export let error = null
export let enableTime = true
export let timeOnly = false
export let time24hr = false
export let placeholder = null
export let appendTo = undefined
export let ignoreTimezones = false
@ -30,6 +31,7 @@
{placeholder}
{enableTime}
{timeOnly}
{time24hr}
{appendTo}
{ignoreTimezones}
on:change={onChange}

View file

@ -10,6 +10,7 @@
export let error = null
export let fileSizeLimit = undefined
export let processFiles = undefined
export let deleteAttachments = undefined
export let handleFileTooLarge = undefined
export let handleTooManyFiles = undefined
export let gallery = true
@ -30,6 +31,7 @@
{value}
{fileSizeLimit}
{processFiles}
{deleteAttachments}
{handleFileTooLarge}
{handleTooManyFiles}
{gallery}

View file

@ -12,6 +12,7 @@
export let direction = "vertical"
export let getOptionLabel = option => extractProperty(option, "label")
export let getOptionValue = option => extractProperty(option, "value")
export let getOptionTitle = option => extractProperty(option, "label")
const dispatch = createEventDispatcher()
const onChange = e => {
@ -35,6 +36,7 @@
{direction}
{getOptionLabel}
{getOptionValue}
{getOptionTitle}
on:change={onChange}
/>
</Field>

View file

@ -83,4 +83,9 @@
transform: translateX(-50%);
text-align: center;
}
.spectrum-Icon--sizeXS {
width: 10px;
height: 10px;
}
</style>

View file

@ -1,5 +1,6 @@
<script>
import { createEventDispatcher, getContext } from "svelte"
import Icon from "../Icon/Icon.svelte"
const dispatch = createEventDispatcher()
const actionMenu = getContext("actionMenu")
@ -8,6 +9,22 @@
export let icon = undefined
export let disabled = undefined
export let noClose = false
export let keyBind = undefined
$: keys = getKeys(keyBind)
const getKeys = keyBind => {
let keys = keyBind?.split("+") || []
for (let i = 0; i < keys.length; i++) {
if (
keys[i].toLowerCase() === "ctrl" &&
navigator.platform.startsWith("Mac")
) {
keys[i] = "⌘"
}
}
return keys
}
const onClick = () => {
if (actionMenu && !noClose) {
@ -26,20 +43,54 @@
tabindex="0"
>
{#if icon}
<svg
class="spectrum-Icon spectrum-Icon--sizeS spectrum-Menu-itemIcon"
focusable="false"
aria-hidden="true"
aria-label={icon}
>
<use xlink:href="#spectrum-icon-18-{icon}" />
</svg>
<div class="icon">
<Icon name={icon} size="S" />
</div>
{/if}
<span class="spectrum-Menu-itemLabel"><slot /></span>
{#if keys?.length}
<div class="keys">
{#each keys as key}
<div class="key">
{#if key.startsWith("!")}
<Icon size="XS" name={key.split("!")[1]} />
{:else}
{key}
{/if}
</div>
{/each}
</div>
{/if}
</li>
<style>
.spectrum-Menu-itemIcon {
.icon {
align-self: center;
margin-right: var(--spacing-s);
}
.keys {
margin-left: 30px;
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
gap: 4px;
}
.key {
color: var(--spectrum-global-color-gray-900);
padding: 2px 4px;
font-size: 12px;
font-weight: 600;
background-color: var(--spectrum-global-color-gray-300);
border-radius: 4px;
min-width: 12px;
height: 16px;
text-align: center;
margin: -1px 0;
display: grid;
place-items: center;
}
.is-disabled .key {
color: var(--spectrum-global-color-gray-600);
}
</style>

View file

@ -11,6 +11,8 @@
const dispatch = createEventDispatcher()
let visible = fixed || inline
let modal
$: dispatch(visible ? "show" : "hide")
export function show() {
@ -41,12 +43,22 @@
}
}
async function focusFirstInput(node) {
async function focusModal(node) {
await tick()
// Try to focus first input
const inputs = node.querySelectorAll("input")
if (inputs?.length) {
await tick()
inputs[0].focus()
}
// Otherwise try to focus confirmation button
else if (modal) {
const confirm = modal.querySelector(".confirm-wrap .spectrum-Button")
if (confirm) {
confirm.focus()
}
}
}
setContext(Context.Modal, { show, hide, cancel })
@ -56,7 +68,7 @@
{#if inline}
{#if visible}
<div use:focusFirstInput class="spectrum-Modal inline is-open">
<div use:focusModal bind:this={modal} class="spectrum-Modal inline is-open">
<slot />
</div>
{/if}
@ -70,17 +82,18 @@
-->
<Portal target=".modal-container">
{#if visible}
<div
class="spectrum-Underlay is-open"
in:fade={{ duration: 200 }}
out:fade|local={{ duration: 200 }}
on:mousedown|self={cancel}
>
<div class="spectrum-Underlay is-open" on:mousedown|self={cancel}>
<div
class="background"
in:fade={{ duration: 200 }}
out:fade|local={{ duration: 200 }}
/>
<div class="modal-wrapper" on:mousedown|self={cancel}>
<div class="modal-inner-wrapper" on:mousedown|self={cancel}>
<slot name="outside" />
<div
use:focusFirstInput
use:focusModal
bind:this={modal}
class="spectrum-Modal is-open"
in:fly={{ y: 30, duration: 200 }}
out:fly|local={{ y: 30, duration: 200 }}
@ -103,7 +116,17 @@
z-index: 999;
overflow: auto;
overflow-x: hidden;
background: rgba(0, 0, 0, 0.75);
background: transparent;
}
.background {
background: var(--modal-background, rgba(0, 0, 0, 0.75));
position: fixed;
top: 0;
left: 0;
height: 100vh;
width: 100vw;
opacity: 0.65;
pointer-events: none;
}
.modal-wrapper {

View file

@ -63,7 +63,7 @@
<style>
.spectrum-Popover {
min-width: var(--spectrum-global-dimension-size-2000) !important;
min-width: var(--spectrum-global-dimension-size-2000);
}
.spectrum-Popover.is-open.spectrum-Popover--withTip {
margin-top: var(--spacing-xs);

View file

@ -23,7 +23,7 @@ filterTests(["smoke", "all"], () => {
cy.get(interact.SPECTRUM_ICON).click({ force: true })
})
cy.get(interact.SPECTRUM_MENU).within(() => {
cy.get(interact.SPECTRUM_MENU_ITEM).contains("Force Password Reset").click({ force: true })
cy.get(interact.SPECTRUM_MENU_ITEM).contains("Force password reset").click({ force: true })
})
cy.get(interact.SPECTRUM_DIALOG_GRID)
@ -41,10 +41,25 @@ filterTests(["smoke", "all"], () => {
cy.get(interact.SPECTRUM_TEXTFIELD_INPUT).eq(i).type("test")
}
cy.get(interact.SPECTRUM_BUTTON).contains("Reset your password").click({ force: true })
//cy.logoutNoAppGrid()
})
it("should verify Standard Portal", () => {
// Development access should be disabled (Admin access is already disabled)
cy.login()
cy.setUserRole("bbuser", "App User")
bbUserLogin()
// Verify Standard Portal
cy.get(interact.SPECTRUM_SIDENAV).should('not.exist') // No config sections
cy.get(interact.CREATE_APP_BUTTON).should('not.exist') // No create app button
cy.get(".app").should('not.exist') // No apps -> no roles assigned to user
cy.get(interact.CONTAINER).should('contain', bbUserEmail) // Message containing users email
cy.logoutNoAppGrid()
})
xit("should verify Admin Portal", () => {
it("should verify Admin Portal", () => {
cy.login()
// Configure user role
cy.setUserRole("bbuser", "Admin")
@ -86,21 +101,6 @@ filterTests(["smoke", "all"], () => {
cy.logOut()
})
it("should verify Standard Portal", () => {
// Development access should be disabled (Admin access is already disabled)
cy.login()
cy.setUserRole("bbuser", "App User")
bbUserLogin()
// Verify Standard Portal
cy.get(interact.SPECTRUM_SIDENAV).should('not.exist') // No config sections
cy.get(interact.CREATE_APP_BUTTON).should('not.exist') // No create app button
cy.get(".app").should('not.exist') // No apps -> no roles assigned to user
cy.get(interact.CONTAINER).should('contain', bbUserEmail) // Message containing users email
cy.logoutNoAppGrid()
})
const bbUserLogin = () => {
// Login as bbuser
cy.logOut()

View file

@ -0,0 +1,178 @@
import filterTests from "../../support/filterTests"
// const interact = require("../support/interact")
filterTests(["smoke", "all"], () => {
context("Auth Configuration", () => {
before(() => {
cy.login()
})
after(() => {
cy.get(".spectrum-SideNav li").contains("Auth").click()
cy.location().should(loc => {
expect(loc.pathname).to.eq("/builder/portal/manage/auth")
})
cy.get("[data-cy=new-scope-input]").clear()
cy.get("div.content").scrollTo("bottom")
cy.get("[data-cy=oidc-active]").click()
cy.get("[data-cy=oidc-active]").should('not.be.checked')
cy.intercept("POST", "/api/global/configs").as("updateAuth")
cy.get("button[data-cy=oidc-save]").contains("Save").click({force: true})
cy.wait("@updateAuth")
cy.get("@updateAuth").its("response.statusCode").should("eq", 200)
cy.get(".spectrum-Toast-content")
.contains("Settings saved")
.should("be.visible")
})
it("Should allow updating of the OIDC config", () => {
cy.get(".spectrum-SideNav li").contains("Auth").click()
cy.location().should(loc => {
expect(loc.pathname).to.eq("/builder/portal/manage/auth")
})
cy.get("div.content").scrollTo("bottom")
cy.get(".spectrum-Toast .spectrum-ClearButton").click()
cy.get("input[data-cy=configUrl]").type("http://budi-auth.com/v2")
cy.get("input[data-cy=clientID]").type("34ac6a13-f24a-4b52-c70d-fa544ffd11b2")
cy.get("input[data-cy=clientSecret]").type("12A8Q~4nS_DWhOOJ2vWIRsNyDVsdtXPD.Zxa9df_")
cy.get("button[data-cy=oidc-save]").should("not.be.disabled");
cy.intercept("POST", "/api/global/configs").as("updateAuth")
cy.get("button[data-cy=oidc-save]").contains("Save").click({force: true})
cy.wait("@updateAuth")
cy.get("@updateAuth").its("response.statusCode").should("eq", 200)
cy.get(".spectrum-Toast-content")
.contains("Settings saved")
.should("be.visible")
})
it("Should display default scopes in advanced config.", () => {
cy.get(".spectrum-SideNav li").contains("Auth").click()
cy.location().should(loc => {
expect(loc.pathname).to.eq("/builder/portal/manage/auth")
})
cy.get("div.content").scrollTo("bottom")
cy.get(".spectrum-Tags").find(".spectrum-Tags-item").its("length").should("eq", 4)
cy.get(".spectrum-Tags-item").contains("openid")
cy.get(".spectrum-Tags-item").contains("openid").find(".spectrum-ClearButton").should("not.exist")
cy.get(".spectrum-Tags-item").contains("offline_access")
cy.get(".spectrum-Tags-item").contains("email")
cy.get(".spectrum-Tags-item").contains("profile")
})
it("Add a new scopes", () => {
cy.get(".spectrum-SideNav li").contains("Auth").click()
cy.location().should(loc => {
expect(loc.pathname).to.eq("/builder/portal/manage/auth")
})
cy.get("div.content").scrollTo("bottom")
cy.get("[data-cy=new-scope-input]").type("Sample{enter}")
cy.get(".spectrum-Tags").find(".spectrum-Tags-item").its("length").should("eq", 5)
cy.get(".spectrum-Tags-item").contains("Sample")
cy.get(".auth-form input.spectrum-Textfield-input").type("Another ")
cy.get(".spectrum-Tags").find(".spectrum-Tags-item").its("length").should("eq", 6)
cy.get(".spectrum-Tags-item").contains("Another")
cy.get("button[data-cy=oidc-save]").should("not.be.disabled");
cy.intercept("POST", "/api/global/configs").as("updateAuth")
cy.get("button[data-cy=oidc-save]").contains("Save").click({force: true})
cy.wait("@updateAuth")
cy.get("@updateAuth").its("response.statusCode").should("eq", 200)
cy.reload()
cy.get("div.content").scrollTo("bottom")
cy.get(".spectrum-Tags-item").contains("openid")
cy.get(".spectrum-Tags-item").contains("offline_access")
cy.get(".spectrum-Tags-item").contains("email")
cy.get(".spectrum-Tags-item").contains("profile")
cy.get(".spectrum-Tags-item").contains("Sample")
cy.get(".spectrum-Tags-item").contains("Another")
})
it("Should allow the removal of auth scopes", () => {
cy.get(".spectrum-SideNav li").contains("Auth").click()
cy.location().should(loc => {
expect(loc.pathname).to.eq("/builder/portal/manage/auth")
})
cy.get("div.content").scrollTo("bottom")
cy.get(".spectrum-Tags-item").contains("offline_access").parent().find(".spectrum-ClearButton").click()
cy.get(".spectrum-Tags-item").contains("profile").parent().find(".spectrum-ClearButton").click()
cy.get(".spectrum-Tags").find(".spectrum-Tags-item").its("length").should("eq", 4)
cy.get(".spectrum-Tags-item").contains("offline_access").should("not.exist")
cy.get(".spectrum-Tags-item").contains("profile").should("not.exist")
cy.get("button[data-cy=oidc-save]").should("not.be.disabled");
cy.intercept("POST", "/api/global/configs").as("updateAuth")
cy.get("button[data-cy=oidc-save]").contains("Save").click({force: true})
cy.wait("@updateAuth")
cy.get("@updateAuth").its("response.statusCode").should("eq", 200)
cy.get(".spectrum-Toast-content")
.contains("Settings saved")
.should("be.visible")
cy.reload()
cy.get(".spectrum-Tags").find(".spectrum-Tags-item").its("length").should("eq", 4)
cy.get(".spectrum-Tags-item").contains("offline_access").should("not.exist")
cy.get(".spectrum-Tags-item").contains("profile").should("not.exist")
})
it("Should allow auth scopes to be reset to the core defaults.", () => {
cy.get(".spectrum-SideNav li").contains("Auth").click()
cy.get("div.content").scrollTo("bottom")
cy.get("[data-cy=restore-oidc-default-scopes]").click({force: true})
cy.get(".spectrum-Tags").find(".spectrum-Tags-item").its("length").should("eq", 4)
cy.get(".spectrum-Tags-item").contains("openid")
cy.get(".spectrum-Tags-item").contains("offline_access")
cy.get(".spectrum-Tags-item").contains("email")
cy.get(".spectrum-Tags-item").contains("profile")
})
it("Should not allow invalid characters in the auth scopes", () => {
cy.get("[data-cy=new-scope-input]").type("thisIsInvalid\\{enter}")
cy.get(".spectrum-Form-itemField .error").contains("Auth scopes cannot contain spaces, double quotes or backslashes")
cy.get(".spectrum-Tags").find(".spectrum-Tags-item").its("length").should("eq", 4)
cy.get("[data-cy=new-scope-input]").clear()
cy.get("[data-cy=new-scope-input]").type("alsoInvalid\"{enter}")
cy.get(".spectrum-Form-itemField .error").contains("Auth scopes cannot contain spaces, double quotes or backslashes")
cy.get(".spectrum-Tags").find(".spectrum-Tags-item").its("length").should("eq", 4)
cy.get("[data-cy=new-scope-input]").clear()
})
it("Should not allow duplicate auth scopes", () => {
cy.get("[data-cy=new-scope-input]").type("offline_access{enter}")
cy.get(".spectrum-Form-itemField .error").contains("Auth scope already exists")
cy.get(".spectrum-Tags").find(".spectrum-Tags-item").its("length").should("eq", 4)
})
})
})

View file

@ -17,7 +17,7 @@ filterTests(["smoke", "all"], () => {
it("should confirm App User role for a New User", () => {
cy.contains("bbuser").click()
cy.get(".spectrum-Form-itemField").eq(2).should('contain', 'App User')
cy.get(".spectrum-Form-itemField").eq(3).should('contain', 'App User')
// User should not have app access
cy.get(interact.LIST_ITEMS, { timeout: 500 }).should("contain", "No apps")
@ -166,12 +166,12 @@ filterTests(["smoke", "all"], () => {
it("Should edit user details within user details page", () => {
// Add First name
cy.get(interact.FIELD, { timeout: 1000 }).eq(0).within(() => {
cy.get(interact.FIELD, { timeout: 1000 }).eq(1).within(() => {
cy.wait(500)
cy.get(interact.SPECTRUM_TEXTFIELD_INPUT, { timeout: 1000 }).wait(500).clear().click().type("bb")
})
// Add Last name
cy.get(interact.FIELD, { timeout: 1000 }).eq(1).within(() => {
cy.get(interact.FIELD, { timeout: 1000 }).eq(2).within(() => {
cy.wait(500)
cy.get(interact.SPECTRUM_TEXTFIELD_INPUT, { timeout: 1000 }).click().wait(500).clear().type("test")
})
@ -180,10 +180,10 @@ filterTests(["smoke", "all"], () => {
cy.reload()
// Confirm details have been saved
cy.get(interact.FIELD, { timeout: 1000 }).eq(0).within(() => {
cy.get(interact.FIELD, { timeout: 1000 }).eq(1).within(() => {
cy.get(interact.SPECTRUM_TEXTFIELD_INPUT).should('have.value', "bb")
})
cy.get(interact.FIELD, { timeout: 1000 }).eq(1).within(() => {
cy.get(interact.FIELD, { timeout: 1000 }).eq(2).within(() => {
cy.get(interact.SPECTRUM_TEXTFIELD_INPUT, { timeout: 1000 }).should('have.value', "test")
})
})
@ -193,13 +193,14 @@ filterTests(["smoke", "all"], () => {
cy.get(interact.SPECTRUM_ICON).click({ force: true })
})
cy.get(interact.SPECTRUM_MENU).within(() => {
cy.get(interact.SPECTRUM_MENU_ITEM).contains("Force Password Reset").click({ force: true })
cy.get(interact.SPECTRUM_MENU_ITEM).contains("Force password reset").click({ force: true })
})
// Reset password modal
cy.get(interact.SPECTRUM_DIALOG_GRID)
.find(interact.SPECTRUM_TEXTFIELD_INPUT).invoke('val').as('pwd')
cy.get(interact.SPECTRUM_BUTTON).contains("Reset password").click({ force: true })
cy.get(interact.SPECTRUM_BUTTON).contains("Reset password").should('not.exist')
// Logout, then login with new password
cy.logOut()
@ -214,6 +215,7 @@ filterTests(["smoke", "all"], () => {
cy.get(interact.SPECTRUM_BUTTON).contains("Reset your password").click({ force: true })
// Confirm user logged in afer password change
cy.login("bbuser@test.com", "test")
cy.get(".avatar > .icon").click({ force: true })
cy.get(".spectrum-Menu-item").contains("Update user information").click({ force: true })

View file

@ -19,10 +19,10 @@ filterTests(["smoke", "all"], () => {
cy.contains("Users").click()
cy.contains("test@test.com").click()
cy.get(interact.FIELD, { timeout: 1000 }).eq(0).within(() => {
cy.get(interact.FIELD, { timeout: 1000 }).eq(1).within(() => {
cy.get(interact.SPECTRUM_TEXTFIELD_INPUT).should('have.value', fname)
})
cy.get(interact.FIELD).eq(1).within(() => {
cy.get(interact.FIELD).eq(2).within(() => {
cy.get(interact.SPECTRUM_TEXTFIELD_INPUT).should('have.value', lname)
})
})
@ -72,7 +72,7 @@ filterTests(["smoke", "all"], () => {
})
// Logout & in with new password
cy.logOut()
//cy.logOut()
cy.login("test@test.com", "newpwd")
})
@ -90,7 +90,6 @@ filterTests(["smoke", "all"], () => {
cy.get(interact.SPECTRUM_MENU_ITEM).contains("Open developer mode").click({ force: true })
cy.get(interact.SPECTRUM_SIDENAV).should('exist') // config sections available
cy.get(interact.CREATE_APP_BUTTON).should('exist') // create app button available
cy.get(interact.APP_TABLE).should('exist') // App table available
})
after(() => {

View file

@ -266,7 +266,7 @@ filterTests(["all"], () => {
cy.reload()
cy.log("Current deployment version: " + clientPackage.version)
cy.get(".version-status a", { timeout: 1000 }).contains("Update").click()
cy.get(".version-status a", { timeout: 5000 }).contains("Update").click()
cy.get(".spectrum-Tabs-item.is-selected").contains("Settings")
cy.get(".version-section .page-action button")

View file

@ -102,7 +102,7 @@ filterTests(['all'], () => {
cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 6000 })
cy.wait(500)
cy.get(interact.APP_TABLE_STATUS, { timeout: 1000 }).eq(0).contains("Unpublished")
cy.get(interact.APP_TABLE_STATUS, { timeout: 10000 }).eq(0).contains("Unpublished")
})
})

View file

@ -94,6 +94,7 @@ filterTests(['smoke', 'all'], () => {
})
it("should create the first application from scratch with a default name", () => {
cy.updateUserInformation("", "")
cy.createApp("", false)
cy.applicationInAppTable("My app")
cy.deleteApp("My app")

View file

@ -48,7 +48,7 @@ filterTests(["smoke", "all"], () => {
it("deletes a row", () => {
cy.get(interact.SPECTRUM_CHECKBOX_INPUT).check({ force: true })
cy.contains("Delete 1 row(s)").click()
cy.contains("Delete 1 row").click()
cy.get(interact.SPECTRUM_MODAL).contains("Delete").click()
cy.contains("RoverUpdated").should("not.exist")
})

View file

@ -175,7 +175,10 @@ filterTests(["all"], () => {
cy.get("@query").its("response.statusCode").should("eq", 200)
cy.get("@query").its("response.body").should("not.be.empty")
// Save query
cy.intercept("POST", "**/queries").as("saveQuery")
cy.get(".spectrum-Button").contains("Save Query").click({ force: true })
cy.wait("@saveQuery")
cy.get("@saveQuery").its("response.statusCode").should("eq", 200)
cy.get(".nav-item").should("contain", queryName)
})

View file

@ -252,7 +252,8 @@ filterTests(["all"], () => {
.contains("Delete Query")
.click({ force: true })
// Confirm deletion
cy.reload({ timeout: 5000 })
cy.reload()
cy.get(".nav-item", { timeout: 30000 }).contains(datasource).click({ force: true })
cy.get(".nav-item", { timeout: 1000 }).should("not.contain", queryRename)
})

View file

@ -48,6 +48,7 @@ filterTests(['smoke', 'all'], () => {
cy.get(interact.AREA_LABEL_REVERT).click({ force: true })
})
cy.get(interact.SPECTRUM_DIALOG_GRID).within(() => {
cy.get("input").type("Cypress Tests")
// Click Revert
cy.get(interact.SPECTRUM_BUTTON).contains("Revert").click({ force: true })
cy.wait(2000) // Wait for app to finish reverting

View file

@ -128,7 +128,9 @@ Cypress.Commands.add("updateUserInformation", (firstName, lastName) => {
.should("have.value", lastName)
.blur()
}
cy.get("button").contains("Update information").click({ force: true })
cy.get(".confirm-wrap").within(() => {
cy.get("button").contains("Update information").click({ force: true })
})
cy.get(".spectrum-Dialog-grid").should("not.exist")
})
})
@ -140,14 +142,14 @@ Cypress.Commands.add("setUserRole", (user, role) => {
// Set Role
cy.wait(500)
cy.get(".spectrum-Form-itemField")
.eq(2)
.eq(3)
.within(() => {
cy.get(".spectrum-Picker-label").click({ force: true })
})
cy.get(".spectrum-Menu").within(() => {
cy.get(".spectrum-Menu-itemLabel").contains(role).click({ force: true })
})
cy.get(".spectrum-Form-itemField").eq(2).should("contain", role)
cy.get(".spectrum-Form-itemField").eq(3).should("contain", role)
})
// APPLICATIONS
@ -162,7 +164,7 @@ Cypress.Commands.add("createApp", (name, addDefaultTable) => {
typeof addDefaultTable != "boolean" ? true : addDefaultTable
cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 10000 })
cy.wait(1000)
cy.url({ timeout: 30000 }).should("include", "/apps")
cy.get(`[data-cy="create-app-btn"]`, { timeout: 5000 }).click({ force: true })
// If apps already exist
@ -432,6 +434,7 @@ Cypress.Commands.add("createAppFromScratch", appName => {
// TABLES
Cypress.Commands.add("createTable", (tableName, initialTable) => {
// Creates an internal Budibase DB table
if (!initialTable) {
cy.navigateToDataSection()
cy.get(`[data-cy="new-table"]`, { timeout: 2000 }).click()
@ -445,6 +448,7 @@ Cypress.Commands.add("createTable", (tableName, initialTable) => {
.contains("Continue")
.click({ force: true })
})
cy.get(".spectrum-Modal").contains("Create Table", { timeout: 10000 })
cy.get(".spectrum-Modal", { timeout: 2000 }).within(() => {
cy.get("input", { timeout: 2000 }).first().type(tableName).blur()
cy.get(".spectrum-ButtonGroup").contains("Create").click()
@ -735,8 +739,15 @@ Cypress.Commands.add("deleteAllScreens", () => {
Cypress.Commands.add("navigateToFrontend", () => {
// Clicks on Design tab and then the Home nav item
cy.wait(500)
cy.intercept("**/preview").as("preview")
cy.contains("Design").click()
cy.get(".spectrum-Search", { timeout: 2000 }).type("/")
cy.wait("@preview")
cy.get("@preview").then(res => {
if (res.statusCode != 200) {
cy.reload()
}
})
cy.get(".spectrum-Search", { timeout: 20000 }).type("/")
cy.get(".nav-item", { timeout: 2000 }).contains("home").click({ force: true })
})

View file

@ -1,6 +1,6 @@
{
"name": "@budibase/builder",
"version": "1.2.58",
"version": "1.2.59-alpha.0",
"license": "GPL-3.0",
"private": true,
"scripts": {
@ -13,11 +13,11 @@
"cy:setup:ci": "node ./cypress/setup.js",
"cy:open": "cypress open",
"cy:run": "cypress run",
"cy:run:ci": "cypress run --headed --browser chrome --spec cypress/integration/createApp.spec.js",
"cy:run:ci": "cypress run --headed --browser chrome --spec cypress/integration/createApp.spec.js",
"cy:run:ci:record": "xvfb-run cypress run --headed --browser chrome --record",
"cy:test": "start-server-and-test cy:setup http://localhost:4100/builder cy:run",
"cy:ci": "start-server-and-test cy:setup:ci http://localhost:4100/builder cy:run:ci",
"cy:ci:record": "start-server-and-test cy:setup:ci http://localhost:4100/builder cy:run:ci:record && npm run cy:ci:report",
"cy:ci:record": "start-server-and-test cy:setup:ci http://localhost:4100/builder cy:run:ci:record; npm run cy:ci:report",
"cy:ci:report": "mochawesome-merge cypress/reports/*.json > cypress/reports/testReport.json && marge cypress/reports/testReport.json --reportDir cypress/reports --inline",
"cy:ci:notify": "node scripts/cypressResultsWebhook",
"cy:debug": "start-server-and-test cy:setup http://localhost:4100/builder cy:open",
@ -69,10 +69,10 @@
}
},
"dependencies": {
"@budibase/bbui": "^1.2.58",
"@budibase/client": "^1.2.58",
"@budibase/frontend-core": "^1.2.58",
"@budibase/string-templates": "^1.2.58",
"@budibase/bbui": "1.2.59-alpha.0",
"@budibase/client": "1.2.59-alpha.0",
"@budibase/frontend-core": "1.2.59-alpha.0",
"@budibase/string-templates": "1.2.59-alpha.0",
"@sentry/browser": "5.19.1",
"@spectrum-css/page": "^3.0.1",
"@spectrum-css/vars": "^3.0.1",

View file

@ -5,7 +5,6 @@ const path = require("path")
const fs = require("fs")
const WEBHOOK_URL = process.env.CYPRESS_WEBHOOK_URL
const OUTCOME = process.env.CYPRESS_OUTCOME
const DASHBOARD_URL = process.env.CYPRESS_DASHBOARD_URL
const GIT_SHA = process.env.GITHUB_SHA
const GITHUB_ACTIONS_RUN_URL = process.env.GITHUB_ACTIONS_RUN_URL
@ -35,6 +34,8 @@ async function discordCypressResultsNotification(report) {
skipped,
} = report.stats
const OUTCOME = failures > 0 ? "failure" : "success"
const options = {
method: "POST",
headers: {
@ -114,7 +115,7 @@ async function discordCypressResultsNotification(report) {
}
const response = await fetch(WEBHOOK_URL, options)
if (response.status >= 400) {
if (response.status >= 201) {
const text = await response.text()
console.error(
`Error sending discord webhook. \nStatus: ${response.status}. \nResponse Body: ${text}. \nRequest Body: ${options.body}`

View file

@ -19,7 +19,6 @@ import {
makeComponentUnique,
} from "../componentUtils"
import { Helpers } from "@budibase/bbui"
import { DefaultAppTheme, LAYOUT_NAMES } from "../../constants"
import { Utils } from "@budibase/frontend-core"
const INITIAL_FRONTEND_STATE = {
@ -40,6 +39,7 @@ const INITIAL_FRONTEND_STATE = {
devicePreview: false,
messagePassing: false,
continueIfAction: false,
showNotificationAction: false,
},
errors: [],
hasAppPackage: false,
@ -124,35 +124,6 @@ export const getFrontendStore = () => {
await integrations.init()
await queries.init()
await tables.init()
// Add navigation settings to old apps
if (!application.navigation) {
const layout = layouts.find(x => x._id === LAYOUT_NAMES.MASTER.PRIVATE)
const customTheme = application.customTheme
let navigationSettings = {
navigation: "Top",
title: application.name,
navWidth: "Large",
navBackground:
customTheme?.navBackground || DefaultAppTheme.navBackground,
navTextColor:
customTheme?.navTextColor || DefaultAppTheme.navTextColor,
}
if (layout) {
navigationSettings.hideLogo = layout.props.hideLogo
navigationSettings.hideTitle = layout.props.hideTitle
navigationSettings.title = layout.props.title || application.name
navigationSettings.logoUrl = layout.props.logoUrl
navigationSettings.links = layout.props.links
navigationSettings.navigation = layout.props.navigation || "Top"
navigationSettings.sticky = layout.props.sticky
navigationSettings.navWidth = layout.props.width || "Large"
if (navigationSettings.navigation === "None") {
navigationSettings.navigation = "Top"
}
}
await store.actions.navigation.save(navigationSettings)
}
},
theme: {
save: async theme => {
@ -534,7 +505,16 @@ export const getFrontendStore = () => {
if (!component) {
return
}
let parentId
// Determine the next component to select after deletion
const state = get(store)
let nextSelectedComponentId
if (state.selectedComponentId === component._id) {
nextSelectedComponentId = store.actions.components.getNext()
if (!nextSelectedComponentId) {
nextSelectedComponentId = store.actions.components.getPrevious()
}
}
// Patch screen
await store.actions.screens.patch(screen => {
@ -549,17 +529,18 @@ export const getFrontendStore = () => {
if (!parent) {
return false
}
parentId = parent._id
parent._children = parent._children.filter(
child => child._id !== component._id
)
})
// Select the deleted component's parent
store.update(state => {
state.selectedComponentId = parentId
return state
})
// Update selected component if required
if (nextSelectedComponentId) {
store.update(state => {
state.selectedComponentId = nextSelectedComponentId
return state
})
}
},
copy: (component, cut = false, selectParent = true) => {
// Update store with copied component
@ -618,6 +599,16 @@ export const getFrontendStore = () => {
}
}
// Check inside is valid
if (mode === "inside") {
const definition = store.actions.components.getDefinition(
targetComponent._component
)
if (!definition.hasChildren) {
mode = "below"
}
}
// Paste new component
if (mode === "inside") {
// Paste inside target component if chosen
@ -654,46 +645,193 @@ export const getFrontendStore = () => {
return state
})
},
getPrevious: () => {
const state = get(store)
const componentId = state.selectedComponentId
const screen = get(selectedScreen)
const parent = findComponentParent(screen.props, componentId)
// Check we aren't right at the top of the tree
const index = parent?._children.findIndex(x => x._id === componentId)
if (!parent || componentId === screen.props._id) {
return null
}
// If we have siblings above us, choose the sibling or a descendant
if (index > 0) {
// If sibling before us accepts children, select a descendant
const previousSibling = parent._children[index - 1]
if (previousSibling._children?.length) {
let target = previousSibling
while (target._children?.length) {
target = target._children[target._children.length - 1]
}
return target._id
}
// Otherwise just select sibling
return previousSibling._id
}
// If no siblings above us, select the parent
return parent._id
},
getNext: () => {
const component = get(selectedComponent)
const componentId = component?._id
const screen = get(selectedScreen)
const parent = findComponentParent(screen.props, componentId)
const index = parent?._children.findIndex(x => x._id === componentId)
// If we have children, select first child
if (component._children?.length) {
return component._children[0]._id
} else if (!parent) {
return null
}
// Otherwise select the next sibling if we have one
if (index < parent._children.length - 1) {
const nextSibling = parent._children[index + 1]
return nextSibling._id
}
// Last child, select our parents next sibling
let target = parent
let targetParent = findComponentParent(screen.props, target._id)
let targetIndex = targetParent?._children.findIndex(
child => child._id === target._id
)
while (
targetParent != null &&
targetIndex === targetParent._children?.length - 1
) {
target = targetParent
targetParent = findComponentParent(screen.props, target._id)
targetIndex = targetParent?._children.findIndex(
child => child._id === target._id
)
}
if (targetParent) {
return targetParent._children[targetIndex + 1]._id
} else {
return null
}
},
selectPrevious: () => {
const previousId = store.actions.components.getPrevious()
if (previousId) {
store.update(state => {
state.selectedComponentId = previousId
return state
})
}
},
selectNext: () => {
const nextId = store.actions.components.getNext()
if (nextId) {
store.update(state => {
state.selectedComponentId = nextId
return state
})
}
},
moveUp: async component => {
await store.actions.screens.patch(screen => {
const componentId = component?._id
const parent = findComponentParent(screen.props, componentId)
if (!parent?._children?.length) {
return false
// Check we aren't right at the top of the tree
const index = parent?._children.findIndex(x => x._id === componentId)
if (!parent || (index === 0 && parent._id === screen.props._id)) {
return
}
const currentIndex = parent._children.findIndex(
child => child._id === componentId
)
if (currentIndex === 0) {
return false
}
const originalComponent = cloneDeep(parent._children[currentIndex])
const newChildren = parent._children.filter(
// Copy original component and remove it from the parent
const originalComponent = cloneDeep(parent._children[index])
parent._children = parent._children.filter(
component => component._id !== componentId
)
newChildren.splice(currentIndex - 1, 0, originalComponent)
parent._children = newChildren
// If we have siblings above us, move up
if (index > 0) {
// If sibling before us accepts children, move to last child of
// sibling
const previousSibling = parent._children[index - 1]
const definition = store.actions.components.getDefinition(
previousSibling._component
)
if (definition.hasChildren) {
previousSibling._children.push(originalComponent)
}
// Otherwise just move component above sibling
else {
parent._children.splice(index - 1, 0, originalComponent)
}
}
// If no siblings above us, go above the parent as long as it isn't
// the screen
else if (parent._id !== screen.props._id) {
const grandParent = findComponentParent(screen.props, parent._id)
const parentIndex = grandParent._children.findIndex(
child => child._id === parent._id
)
grandParent._children.splice(parentIndex, 0, originalComponent)
}
})
},
moveDown: async component => {
await store.actions.screens.patch(screen => {
const componentId = component?._id
const parent = findComponentParent(screen.props, componentId)
// Sanity check parent is found
if (!parent?._children?.length) {
return false
}
const currentIndex = parent._children.findIndex(
child => child._id === componentId
)
if (currentIndex === parent._children.length - 1) {
return false
// Check we aren't right at the bottom of the tree
const index = parent._children.findIndex(x => x._id === componentId)
if (
index === parent._children.length - 1 &&
parent._id === screen.props._id
) {
return
}
const originalComponent = cloneDeep(parent._children[currentIndex])
const newChildren = parent._children.filter(
// Copy the original component and remove from parent
const originalComponent = cloneDeep(parent._children[index])
parent._children = parent._children.filter(
component => component._id !== componentId
)
newChildren.splice(currentIndex + 1, 0, originalComponent)
parent._children = newChildren
// Move below the next sibling if we are not the last sibling
if (index < parent._children.length) {
// If the next sibling has children, become the first child
const nextSibling = parent._children[index]
const definition = store.actions.components.getDefinition(
nextSibling._component
)
if (definition.hasChildren) {
nextSibling._children.splice(0, 0, originalComponent)
}
// Otherwise move below next sibling
else {
parent._children.splice(index + 1, 0, originalComponent)
}
}
// Last child, so move below our parent
else {
const grandParent = findComponentParent(screen.props, parent._id)
const parentIndex = grandParent._children.findIndex(
child => child._id === parent._id
)
grandParent._children.splice(parentIndex + 1, 0, originalComponent)
}
})
},
updateStyle: async (name, value) => {

View file

@ -162,7 +162,7 @@
width="28px"
height="28px"
class="spectrum-Icon"
style="color:grey;"
style="color:var(--spectrum-global-color-gray-700);"
focusable="false"
>
<use xlink:href="#spectrum-icon-18-Reuse" />

View file

@ -64,7 +64,7 @@
width="28px"
height="28px"
class="spectrum-Icon"
style="color:grey;"
style="color:var(--spectrum-global-color-gray-700);"
focusable="false"
>
<use xlink:href="#spectrum-icon-18-{block.icon}" />

View file

@ -23,7 +23,7 @@
</script>
<div class="automations-list">
{#each $automationStore.automations as automation, idx}
{#each $automationStore.automations.sort(aut => aut.name) as automation, idx}
<NavItem
border={idx > 0}
icon="ShareAndroid"

View file

@ -167,6 +167,7 @@
{/if}
<HideAutocolumnButton bind:hideAutocolumns />
<ImportButton
disabled={$tables.selected?._id === "ta_users"}
tableId={$tables.selected?._id}
on:updaterows={onUpdateRows}
/>

View file

@ -3,11 +3,12 @@
import ImportModal from "../modals/ImportModal.svelte"
export let tableId
export let disabled
let modal
</script>
<ActionButton icon="DataUpload" size="S" quiet on:click={modal.show}>
<ActionButton icon="DataUpload" size="S" quiet on:click={modal.show} {disabled}>
Import
</ActionButton>
<Modal bind:this={modal}>

View file

@ -467,6 +467,7 @@
options={relationshipOptions}
getOptionLabel={option => option.name}
getOptionValue={option => option.value}
getOptionTitle={option => option.alt}
/>
{/if}
<Input

View file

@ -6,6 +6,8 @@
Modal,
notifications,
ProgressCircle,
Layout,
Body,
} from "@budibase/bbui"
import { auth, apps } from "stores/portal"
import { processStringSync } from "@budibase/string-templates"
@ -72,62 +74,67 @@
{/if}
</div>
<Modal bind:this={appLockModal}>
<ModalContent
title={lockedByHeading}
dataCy={"app-lock-modal"}
showConfirmButton={false}
showCancelButton={false}
>
<p>
Apps are locked to prevent work from being lost from overlapping changes
between your team.
</p>
{#if lockedByYou && getExpiryDuration(app) > 0}
<span class="lock-expiry-body">
{processStringSync(
"This lock will expire in {{ duration time 'millisecond' }} from now.",
{
time: getExpiryDuration(app),
}
)}
</span>
{/if}
<div class="lock-modal-actions">
<ButtonGroup>
<Button
secondary
quiet={lockedBy && lockedByYou}
disabled={processing}
on:click={() => {
appLockModal.hide()
}}
>
<span class="cancel"
>{lockedBy && !lockedByYou ? "Done" : "Cancel"}</span
>
</Button>
{#if lockedByYou}
<Button
secondary
disabled={processing}
on:click={() => {
releaseLock()
appLockModal.hide()
}}
>
{#if processing}
<ProgressCircle overBackground={true} size="S" />
{:else}
<span class="unlock">Release Lock</span>
{/if}
</Button>
{/if}
</ButtonGroup>
</div>
</ModalContent>
</Modal>
{#key app}
<div>
<Modal bind:this={appLockModal}>
<ModalContent
title={lockedByHeading}
dataCy={"app-lock-modal"}
showConfirmButton={false}
showCancelButton={false}
>
<Layout noPadding>
<Body size="S">
Apps are locked to prevent work from being lost from overlapping
changes between your team.
</Body>
{#if lockedByYou && getExpiryDuration(app) > 0}
<span class="lock-expiry-body">
{processStringSync(
"This lock will expire in {{ duration time 'millisecond' }} from now. This lock will expire in This lock will expire in ",
{
time: getExpiryDuration(app),
}
)}
</span>
{/if}
<div class="lock-modal-actions">
<ButtonGroup>
<Button
secondary
quiet={lockedBy && lockedByYou}
disabled={processing}
on:click={() => {
appLockModal.hide()
}}
>
<span class="cancel"
>{lockedBy && !lockedByYou ? "Done" : "Cancel"}</span
>
</Button>
{#if lockedByYou}
<Button
secondary
disabled={processing}
on:click={() => {
releaseLock()
appLockModal.hide()
}}
>
{#if processing}
<ProgressCircle overBackground={true} size="S" />
{:else}
<span class="unlock">Release Lock</span>
{/if}
</Button>
{/if}
</ButtonGroup>
</div>
</Layout>
</ModalContent>
</Modal>
</div>
{/key}
<style>
.lock-modal-actions {

View file

@ -27,6 +27,14 @@
return []
}
}
async function deleteAttachments(fileList) {
try {
return await API.deleteBuilderAttachments(fileList)
} catch (error) {
return []
}
}
</script>
<Dropzone
@ -34,5 +42,6 @@
{label}
{...$$restProps}
{processFiles}
{deleteAttachments}
{handleFileTooLarge}
/>

View file

@ -16,6 +16,7 @@
export let scrollable = false
export let highlighted = false
export let rightAlignIcon = false
export let id
const scrollApi = getContext("scroll")
const dispatch = createEventDispatcher()
@ -58,6 +59,7 @@
on:click={onClick}
ondragover="return false"
ondragenter="return false"
{id}
>
<div class="nav-item-content" bind:this={contentRef}>
{#if withArrow}

View file

@ -8,6 +8,7 @@
Tab,
Body,
Layout,
Button,
} from "@budibase/bbui"
import { createEventDispatcher, onMount } from "svelte"
import {
@ -15,10 +16,15 @@
decodeJSBinding,
encodeJSBinding,
} from "@budibase/string-templates"
import { readableToRuntimeBinding } from "builderStore/dataBinding"
import {
readableToRuntimeBinding,
runtimeToReadableBinding,
} from "builderStore/dataBinding"
import { handlebarsCompletions } from "constants/completions"
import { addHBSBinding, addJSBinding } from "./utils"
import CodeMirrorEditor from "components/common/CodeMirrorEditor.svelte"
import { convertToJS } from "@budibase/string-templates"
import { admin } from "stores/portal"
const dispatch = createEventDispatcher()
@ -62,15 +68,24 @@
}
}
// Adds a HBS helper to the expression
const addHelper = helper => {
hbsValue = addHBSBinding(hbsValue, getCaretPosition(), helper.text)
updateValue(hbsValue)
// Adds a JS/HBS helper to the expression
const addHelper = (helper, js) => {
let tempVal
const pos = getCaretPosition()
if (js) {
const decoded = decodeJSBinding(jsValue)
tempVal = jsValue = encodeJSBinding(
addJSBinding(decoded, pos, helper.text, { helper: true })
)
} else {
tempVal = hbsValue = addHBSBinding(hbsValue, pos, helper.text)
}
updateValue(tempVal)
}
// Adds a data binding to the expression
const addBinding = binding => {
if (usingJS) {
const addBinding = (binding, { forceJS } = {}) => {
if (usingJS || forceJS) {
let js = decodeJSBinding(jsValue)
js = addJSBinding(js, getCaretPosition(), binding.readableBinding)
jsValue = encodeJSBinding(js)
@ -100,6 +115,26 @@
updateValue(jsValue)
}
const convert = () => {
const runtime = readableToRuntimeBinding(bindings, hbsValue)
const runtimeJs = encodeJSBinding(convertToJS(runtime))
jsValue = runtimeToReadableBinding(bindings, runtimeJs)
hbsValue = null
mode = "JavaScript"
addBinding("", { forceJS: true })
}
const getHelperExample = (helper, js) => {
let example = helper.example || ""
if (js) {
example = convertToJS(example).split("\n")[0].split("= ")[1]
if (example === "null;") {
example = ""
}
}
return example || ""
}
onMount(() => {
valid = isValid(readableToRuntimeBinding(bindings, value))
})
@ -135,18 +170,21 @@
</section>
{/if}
{/each}
{#if filteredHelpers?.length && !usingJS}
{#if filteredHelpers?.length}
<section>
<div class="heading">Helpers</div>
<ul>
{#each filteredHelpers as helper}
<li on:click={() => addHelper(helper)}>
<li on:click={() => addHelper(helper, usingJS)}>
<div class="helper">
<div class="helper__name">{helper.displayText}</div>
<div class="helper__description">
{@html helper.description}
</div>
<pre class="helper__example">{helper.example || ""}</pre>
<pre class="helper__example">{getHelperExample(
helper,
usingJS
)}</pre>
</div>
</li>
{/each}
@ -172,6 +210,11 @@
for more details.
</p>
{/if}
{#if $admin.isDev}
<div class="convert">
<Button secondary on:click={convert}>Convert to JS</Button>
</div>
{/if}
</div>
</Tab>
{#if allowJS}
@ -306,4 +349,8 @@
color: var(--red);
text-decoration: underline;
}
.convert {
padding-top: var(--spacing-m);
}
</style>

View file

@ -18,10 +18,14 @@ export function addHBSBinding(value, caretPos, binding) {
return value
}
export function addJSBinding(value, caretPos, binding) {
export function addJSBinding(value, caretPos, binding, { helper } = {}) {
binding = typeof binding === "string" ? binding : binding.path
value = value == null ? "" : value
binding = `$("${binding}")`
if (!helper) {
binding = `$("${binding}")`
} else {
binding = `helper.${binding}()`
}
if (caretPos.start) {
value =
value.substring(0, caretPos.start) +

View file

@ -0,0 +1,61 @@
<script>
import { Select, Label, Checkbox } from "@budibase/bbui"
import { onMount } from "svelte"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
export let parameters
export let bindings = []
const types = [
{
label: "Success",
value: "success",
},
{
label: "Warning",
value: "warning",
},
{
label: "Error",
value: "error",
},
{
label: "Info",
value: "info",
},
]
onMount(() => {
if (!parameters.type) {
parameters.type = "success"
}
if (parameters.autoDismiss == null) {
parameters.autoDismiss = true
}
})
</script>
<div class="root">
<Label>Type</Label>
<Select bind:value={parameters.type} options={types} placeholder={null} />
<Label>Message</Label>
<DrawerBindableInput
{bindings}
value={parameters.message}
on:change={e => (parameters.message = e.detail)}
/>
<Label />
<Checkbox text="Auto dismiss" bind:value={parameters.autoDismiss} />
</div>
<style>
.root {
display: grid;
column-gap: var(--spacing-l);
row-gap: var(--spacing-s);
grid-template-columns: 60px 1fr;
align-items: center;
max-width: 400px;
margin: 0 auto;
}
</style>

View file

@ -15,3 +15,4 @@ export { default as S3Upload } from "./S3Upload.svelte"
export { default as ExportData } from "./ExportData.svelte"
export { default as ContinueIf } from "./ContinueIf.svelte"
export { default as UpdateFieldValue } from "./UpdateFieldValue.svelte"
export { default as ShowNotification } from "./ShowNotification.svelte"

View file

@ -110,6 +110,12 @@
"type": "logic",
"component": "ContinueIf",
"dependsOnFeature": "continueIfAction"
},
{
"name": "Show Notification",
"type": "application",
"component": "ShowNotification",
"dependsOnFeature": "showNotificationAction"
}
]
}

View file

@ -3,29 +3,41 @@
Body,
Button,
Combobox,
Multiselect,
DatePicker,
DrawerContent,
Icon,
Input,
Layout,
Select,
Label,
} from "@budibase/bbui"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
import { generate } from "shortid"
import { LuceneUtils, Constants } from "@budibase/frontend-core"
import { getFields } from "helpers/searchFields"
import { createEventDispatcher, onMount } from "svelte"
const dispatch = createEventDispatcher()
export let schemaFields
export let filters = []
export let bindings = []
export let panel = ClientBindingPanel
export let allowBindings = true
export let allOr = false
$: dispatch("change", filters)
$: enrichedSchemaFields = getFields(schemaFields || [])
$: fieldOptions = enrichedSchemaFields.map(field => field.name) || []
$: valueTypeOptions = allowBindings ? ["Value", "Binding"] : ["Value"]
let behaviourValue
const behaviourOptions = [
{ value: "and", label: "Match all of the following filters" },
{ value: "or", label: "Match any of the following filters" },
]
const addFilter = () => {
filters = [
...filters,
@ -69,7 +81,7 @@
}
// if changed to an array, change default value to empty array
const idx = filters.findIndex(x => x.field === field)
const idx = filters.findIndex(x => x.id === expression.id)
if (expression.type === "array") {
filters[idx].value = []
} else {
@ -86,12 +98,26 @@
if (expression.noValue) {
expression.value = null
}
if (
operator === Constants.OperatorOptions.In.value &&
!Array.isArray(expression.value)
) {
if (expression.value) {
expression.value = [expression.value]
} else {
expression.value = []
}
}
}
const getFieldOptions = field => {
const schema = enrichedSchemaFields.find(x => x.name === field)
return schema?.constraints?.inclusion || []
}
onMount(() => {
behaviourValue = allOr ? "or" : "and"
})
</script>
<DrawerContent>
@ -107,79 +133,101 @@
</Body>
{#if filters?.length}
<div class="fields">
{#each filters as filter, idx}
<Select
bind:value={filter.field}
options={fieldOptions}
on:change={e => onFieldChange(filter, e.detail)}
placeholder="Column"
/>
<Select
disabled={!filter.field}
options={LuceneUtils.getValidOperatorsForType(filter.type)}
bind:value={filter.operator}
on:change={e => onOperatorChange(filter, e.detail)}
placeholder={null}
/>
<Select
disabled={filter.noValue || !filter.field}
options={valueTypeOptions}
bind:value={filter.valueType}
placeholder={null}
/>
{#if filter.valueType === "Binding"}
<DrawerBindableInput
disabled={filter.noValue}
title={`Value for "${filter.field}"`}
value={filter.value}
placeholder="Value"
{panel}
{bindings}
on:change={event => (filter.value = event.detail)}
<Select
label="Behaviour"
value={behaviourValue}
options={behaviourOptions}
getOptionLabel={opt => opt.label}
getOptionValue={opt => opt.value}
on:change={e => (allOr = e.detail === "or")}
placeholder={null}
/>
</div>
<div>
<div class="filter-label">
<Label>Filters</Label>
</div>
<div class="fields">
{#each filters as filter, idx}
<Select
bind:value={filter.field}
options={fieldOptions}
on:change={e => onFieldChange(filter, e.detail)}
placeholder="Column"
/>
{:else if ["string", "longform", "number", "formula"].includes(filter.type)}
<Input disabled={filter.noValue} bind:value={filter.value} />
{:else if ["options", "array"].includes(filter.type)}
<Combobox
disabled={filter.noValue}
options={getFieldOptions(filter.field)}
bind:value={filter.value}
<Select
disabled={!filter.field}
options={LuceneUtils.getValidOperatorsForType(filter.type)}
bind:value={filter.operator}
on:change={e => onOperatorChange(filter, e.detail)}
placeholder={null}
/>
{:else if filter.type === "boolean"}
<Combobox
disabled={filter.noValue}
options={[
{ label: "True", value: "true" },
{ label: "False", value: "false" },
]}
bind:value={filter.value}
<Select
disabled={filter.noValue || !filter.field}
options={valueTypeOptions}
bind:value={filter.valueType}
placeholder={null}
/>
{:else if filter.type === "datetime"}
<DatePicker
disabled={filter.noValue}
enableTime={!getSchema(filter).dateOnly}
timeOnly={getSchema(filter).timeOnly}
bind:value={filter.value}
{#if filter.valueType === "Binding"}
<DrawerBindableInput
disabled={filter.noValue}
title={`Value for "${filter.field}"`}
value={filter.value}
placeholder="Value"
{panel}
{bindings}
on:change={event => (filter.value = event.detail)}
/>
{:else if ["string", "longform", "number", "formula"].includes(filter.type)}
<Input disabled={filter.noValue} bind:value={filter.value} />
{:else if filter.type === "array" || (filter.type === "options" && filter.operator === "oneOf")}
<Multiselect
disabled={filter.noValue}
options={getFieldOptions(filter.field)}
bind:value={filter.value}
/>
{:else if filter.type === "options"}
<Combobox
disabled={filter.noValue}
options={getFieldOptions(filter.field)}
bind:value={filter.value}
/>
{:else if filter.type === "boolean"}
<Combobox
disabled={filter.noValue}
options={[
{ label: "True", value: "true" },
{ label: "False", value: "false" },
]}
bind:value={filter.value}
/>
{:else if filter.type === "datetime"}
<DatePicker
disabled={filter.noValue}
enableTime={!getSchema(filter).dateOnly}
timeOnly={getSchema(filter).timeOnly}
bind:value={filter.value}
/>
{:else}
<DrawerBindableInput disabled />
{/if}
<Icon
name="Duplicate"
hoverable
size="S"
on:click={() => duplicateFilter(filter.id)}
/>
{:else}
<DrawerBindableInput disabled />
{/if}
<Icon
name="Duplicate"
hoverable
size="S"
on:click={() => duplicateFilter(filter.id)}
/>
<Icon
name="Close"
hoverable
size="S"
on:click={() => removeFilter(filter.id)}
/>
{/each}
<Icon
name="Close"
hoverable
size="S"
on:click={() => removeFilter(filter.id)}
/>
{/each}
</div>
</div>
{/if}
<div>
<div class="bottom">
<Button icon="AddCircle" size="M" secondary on:click={addFilter}>
Add filter
</Button>
@ -202,4 +250,14 @@
align-items: center;
grid-template-columns: 1fr 120px 120px 1fr auto auto;
}
.filter-label {
margin-bottom: var(--spacing-s);
}
.bottom {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

View file

@ -8,21 +8,73 @@
import FilterDrawer from "./FilterDrawer.svelte"
import { currentAsset } from "builderStore"
const QUERY_START_REGEX = /\d[0-9]*:/g
const dispatch = createEventDispatcher()
export let value = []
export let componentInstance
export let bindings = []
let drawer
let tempValue = value || []
let drawer,
toSaveFilters = null,
allOr,
initialAllOr
$: initialFilters = correctFilters(value || [])
$: dataSource = getDatasourceForProvider($currentAsset, componentInstance)
$: schema = getSchemaForDatasource($currentAsset, dataSource)?.schema
$: schemaFields = Object.values(schema || {})
const saveFilter = async () => {
dispatch("change", tempValue)
function addNumbering(filters) {
let count = 1
for (let value of filters) {
if (value.field && value.field?.match(QUERY_START_REGEX) == null) {
value.field = `${count++}:${value.field}`
}
}
return filters
}
function correctFilters(filters) {
const corrected = []
for (let filter of filters) {
let field = filter.field
if (filter.operator === "allOr") {
initialAllOr = allOr = true
continue
}
if (
typeof filter.field === "string" &&
filter.field.match(QUERY_START_REGEX) != null
) {
const parts = field.split(":")
const number = parts[0]
// it's the new format, remove number
if (!isNaN(parseInt(number))) {
parts.shift()
field = parts.join(":")
}
}
corrected.push({
...filter,
field,
})
}
return corrected
}
async function saveFilter() {
if (!toSaveFilters && allOr !== initialAllOr) {
toSaveFilters = initialFilters
}
const filters = toSaveFilters?.filter(filter => filter.operator !== "allOr")
if (allOr && filters) {
filters.push({ operator: "allOr" })
}
// only save if anything was updated
if (filters) {
dispatch("change", addNumbering(filters))
}
notifications.success("Filters saved.")
drawer.hide()
}
@ -33,8 +85,12 @@
<Button cta slot="buttons" on:click={saveFilter}>Save</Button>
<FilterDrawer
slot="body"
bind:filters={tempValue}
filters={initialFilters}
{bindings}
{schemaFields}
bind:allOr
on:change={event => {
toSaveFilters = event.detail
}}
/>
</Drawer>

View file

@ -30,7 +30,7 @@
{/if}
</div>
<div class="desktop">
<AppLockModal {app} buttonSize="M" />
<span><AppLockModal {app} buttonSize="M" /></span>
</div>
<div class="desktop">
<div class="app-status">

View file

@ -55,13 +55,16 @@
let saveId, url
let response, schema, enabledHeaders
let authConfigId
let dynamicVariables, addVariableModal, varBinding
let dynamicVariables, addVariableModal, varBinding, globalDynamicBindings
let restBindings = getRestBindings()
$: staticVariables = datasource?.config?.staticVariables || {}
$: customRequestBindings = toBindingsArray(requestBindings, "Binding")
$: dynamicRequestBindings = toBindingsArray(dynamicVariables, "Dynamic")
$: globalDynamicRequestBindings = toBindingsArray(
globalDynamicBindings,
"Dynamic"
)
$: dataSourceStaticBindings = toBindingsArray(
staticVariables,
"Datasource.Static"
@ -70,7 +73,7 @@
$: mergedBindings = [
...restBindings,
...customRequestBindings,
...dynamicRequestBindings,
...globalDynamicRequestBindings,
...dataSourceStaticBindings,
]
@ -231,11 +234,11 @@
]
// convert dynamic variables list to simple key/val object
const getDynamicVariables = (datasource, queryId) => {
const getDynamicVariables = (datasource, queryId, matchFn) => {
const variablesList = datasource?.config?.dynamicVariables
if (variablesList && variablesList.length > 0) {
const filtered = queryId
? variablesList.filter(variable => variable.queryId === queryId)
? variablesList.filter(variable => matchFn(variable, queryId))
: variablesList
return filtered.reduce(
(acc, next) => ({ ...acc, [next.name]: next.value }),
@ -367,12 +370,21 @@
if (query && !query.fields.pagination) {
query.fields.pagination = {}
}
dynamicVariables = getDynamicVariables(datasource, query._id)
dynamicVariables = getDynamicVariables(
datasource,
query._id,
(variable, queryId) => variable.queryId === queryId
)
globalDynamicBindings = getDynamicVariables(
datasource,
query._id,
(variable, queryId) => variable.queryId !== queryId
)
prettifyQueryRequestBody(
query,
requestBindings,
dynamicVariables,
globalDynamicBindings,
staticVariables,
restBindings
)
@ -437,7 +449,7 @@
valuePlaceholder="Default"
bindings={[
...restBindings,
...dynamicRequestBindings,
...globalDynamicRequestBindings,
...dataSourceStaticBindings,
]}
bindingDrawerLeft="260px"

View file

@ -8,7 +8,6 @@
selectedLayout,
currentAsset,
} from "builderStore"
import iframeTemplate from "./iframeTemplate"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import {
ProgressCircle,
@ -40,12 +39,6 @@
BUDIBASE: "type",
}
// Construct iframe template
$: template = iframeTemplate.replace(
/\{\{ CLIENT_LIB_PATH }}/,
$store.clientLibPath
)
const placeholderScreen = new Screen()
.name("Screen Placeholder")
.route("/")
@ -151,7 +144,11 @@
} else if (type === "update-prop") {
await store.actions.components.updateSetting(data.prop, data.value)
} else if (type === "delete-component" && data.id) {
// Legacy type, can be deleted in future
confirmDeleteComponent(data.id)
} else if (type === "key-down") {
const { key, ctrlKey } = data
document.dispatchEvent(new KeyboardEvent("keydown", { key, ctrlKey }))
} else if (type === "duplicate-component" && data.id) {
const rootComponent = get(currentAsset).props
const component = findComponent(rootComponent, data.id)
@ -293,7 +290,7 @@
<iframe
title="componentPreview"
bind:this={iframe}
srcdoc={template}
src="/preview"
class:hidden={loading || error}
class:tablet={$store.previewDevice === "tablet"}
class:mobile={$store.previewDevice === "mobile"}

View file

@ -1,104 +0,0 @@
export default `
<html>
<head>
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
<link rel="preconnect" href="https://fonts.gstatic.com" />
<link
href="https://fonts.googleapis.com/css2?family=Source+Sans+Pro:wght@400;600;700&display=swap"
rel="stylesheet"
/>
<link
href="https://cdn.jsdelivr.net/npm/remixicon@2.5.0/fonts/remixicon.css"
rel="stylesheet"
/>
<style>
html, body {
padding: 0;
margin: 0;
}
html {
height: 100%;
width: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
}
body {
flex: 1 1 auto;
overflow: hidden;
}
*,
*:before,
*:after {
box-sizing: border-box;
}
</style>
<script src='{{ CLIENT_LIB_PATH }}'></script>
<script>
function receiveMessage(event) {
if (!event.data) {
return
}
// Parse received message
// If parsing fails, just ignore and wait for the next message
let parsed
try {
parsed = JSON.parse(event.data)
} catch (error) {
console.error("Client received invalid JSON")
// Ignore
}
if (!parsed || !parsed.isBudibaseEvent) {
return
}
// Extract data from message
const {
selectedComponentId,
layout,
screen,
appId,
theme,
customTheme,
previewDevice,
navigation,
hiddenComponentIds
} = parsed
// Set some flags so the app knows we're in the builder
window["##BUDIBASE_IN_BUILDER##"] = true
window["##BUDIBASE_APP_ID##"] = appId
window["##BUDIBASE_PREVIEW_LAYOUT##"] = layout
window["##BUDIBASE_PREVIEW_SCREEN##"] = screen
window["##BUDIBASE_SELECTED_COMPONENT_ID##"] = selectedComponentId
window["##BUDIBASE_PREVIEW_ID##"] = Math.random()
window["##BUDIBASE_PREVIEW_THEME##"] = theme
window["##BUDIBASE_PREVIEW_CUSTOM_THEME##"] = customTheme
window["##BUDIBASE_PREVIEW_DEVICE##"] = previewDevice
window["##BUDIBASE_PREVIEW_NAVIGATION##"] = navigation
window["##BUDIBASE_HIDDEN_COMPONENT_IDS##"] = hiddenComponentIds
// Initialise app
try {
if (window.loadBudibase) {
window.loadBudibase()
document.documentElement.classList.add("loaded")
} else {
throw "The client library couldn't be loaded"
}
} catch (error) {
window.parent.postMessage({ type: "error", error })
}
}
window.addEventListener("message", receiveMessage)
window.parent.postMessage({ type: "ready" })
</script>
</head>
<body/>
</html>
`

View file

@ -1,117 +1,79 @@
<script>
import { store } from "builderStore"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { ActionMenu, MenuItem, Icon, notifications } from "@budibase/bbui"
import { ActionMenu, MenuItem, Icon } from "@budibase/bbui"
export let component
let confirmDeleteDialog
$: definition = store.actions.components.getDefinition(component?._component)
$: noChildrenAllowed = !component || !definition?.hasChildren
$: noPaste = !$store.componentToPaste
// "editable" has been repurposed for inline text editing.
// It remains here for legacy compatibility.
// Future components should define "static": true for indicate they should
// not show a context menu.
$: showMenu = definition?.editable !== false && definition?.static !== true
const moveUpComponent = async () => {
try {
await store.actions.components.moveUp(component)
} catch (error) {
notifications.error("Error moving component up")
}
}
const moveDownComponent = async () => {
try {
await store.actions.components.moveDown(component)
} catch (error) {
notifications.error("Error moving component down")
}
}
const duplicateComponent = () => {
storeComponentForCopy(false)
pasteComponent("below")
}
const deleteComponent = async () => {
try {
await store.actions.components.delete(component)
} catch (error) {
notifications.error("Error deleting component")
}
}
const storeComponentForCopy = (cut = false) => {
store.actions.components.copy(component, cut)
}
const pasteComponent = mode => {
try {
store.actions.components.paste(component, mode)
} catch (error) {
notifications.error("Error saving component")
}
const keyboardEvent = (key, ctrlKey = false) => {
document.dispatchEvent(
new CustomEvent("component-menu", {
detail: {
key,
ctrlKey,
id: component?._id,
},
})
)
}
</script>
{#if showMenu}
<ActionMenu>
<div slot="control" class="icon">
<Icon size="S" hoverable name="MoreSmallList" />
</div>
<MenuItem icon="Delete" on:click={confirmDeleteDialog.show}>
Delete
</MenuItem>
<MenuItem noClose icon="ChevronUp" on:click={moveUpComponent}>
Move up
</MenuItem>
<MenuItem noClose icon="ChevronDown" on:click={moveDownComponent}>
Move down
</MenuItem>
<MenuItem noClose icon="Duplicate" on:click={duplicateComponent}>
Duplicate
</MenuItem>
<MenuItem icon="Cut" on:click={() => storeComponentForCopy(true)}>
Cut
</MenuItem>
<MenuItem icon="Copy" on:click={() => storeComponentForCopy(false)}>
Copy
</MenuItem>
<MenuItem
icon="LayersBringToFront"
on:click={() => pasteComponent("above")}
disabled={noPaste}
>
Paste above
</MenuItem>
<MenuItem
icon="LayersSendToBack"
on:click={() => pasteComponent("below")}
disabled={noPaste}
>
Paste below
</MenuItem>
<MenuItem
icon="ShowOneLayer"
on:click={() => pasteComponent("inside")}
disabled={noPaste || noChildrenAllowed}
>
Paste inside
</MenuItem>
</ActionMenu>
<ConfirmDialog
bind:this={confirmDeleteDialog}
title="Confirm Deletion"
body={`Are you sure you wish to delete this '${definition?.name}' component?`}
okText="Delete Component"
onOk={deleteComponent}
/>
{/if}
<ActionMenu>
<div slot="control" class="icon">
<Icon size="S" hoverable name="MoreSmallList" />
</div>
<MenuItem
icon="Delete"
keyBind="!BackAndroid"
on:click={() => keyboardEvent("Delete")}
>
Delete
</MenuItem>
<MenuItem
icon="ChevronUp"
keyBind="Ctrl+!ArrowUp"
on:click={() => keyboardEvent("ArrowUp", true)}
>
Move up
</MenuItem>
<MenuItem
icon="ChevronDown"
keyBind="Ctrl+!ArrowDown"
on:click={() => keyboardEvent("ArrowDown", true)}
>
Move down
</MenuItem>
<MenuItem
icon="Duplicate"
keyBind="Ctrl+D"
on:click={() => keyboardEvent("d", true)}
>
Duplicate
</MenuItem>
<MenuItem
icon="Cut"
keyBind="Ctrl+X"
on:click={() => keyboardEvent("x", true)}
>
Cut
</MenuItem>
<MenuItem
icon="Copy"
keyBind="Ctrl+C"
on:click={() => keyboardEvent("c", true)}
>
Copy
</MenuItem>
<MenuItem
icon="LayersSendToBack"
keyBind="Ctrl+V"
on:click={() => keyboardEvent("v", true)}
disabled={noPaste}
>
Paste
</MenuItem>
</ActionMenu>
<style>
.icon {

View file

@ -0,0 +1,118 @@
<script>
import { onMount } from "svelte"
import { selectedComponent, selectedScreen, store } from "builderStore"
import { findComponent } from "builderStore/componentUtils"
import { goto, isActive } from "@roxi/routify"
import { notifications } from "@budibase/bbui"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
let confirmDeleteDialog
let componentToDelete
const keyHandlers = {
["^ArrowUp"]: async component => {
await store.actions.components.moveUp(component)
},
["^ArrowDown"]: async component => {
await store.actions.components.moveDown(component)
},
["^c"]: component => {
store.actions.components.copy(component, false)
},
["^x"]: component => {
store.actions.components.copy(component, true)
},
["^v"]: async component => {
await store.actions.components.paste(component, "inside")
},
["^d"]: async component => {
store.actions.components.copy(component)
await store.actions.components.paste(component, "below")
},
["^Enter"]: () => {
$goto("./new")
},
["Delete"]: component => {
// Don't show confirmation for the screen itself
if (component?._id === $selectedScreen.props._id) {
return false
}
componentToDelete = component
confirmDeleteDialog.show()
},
["ArrowUp"]: () => {
store.actions.components.selectPrevious()
},
["ArrowDown"]: () => {
store.actions.components.selectNext()
},
["Escape"]: () => {
if (!$isActive("/new")) {
return false
}
$goto("./")
},
}
const handleKeyAction = async (component, key, ctrlKey = false) => {
if (!component || !key) {
return false
}
try {
// Delete and backspace are the same
if (key === "Backspace") {
key = "Delete"
}
// Prefix key with a caret for ctrl modifier
if (ctrlKey) {
key = "^" + key
}
const handler = keyHandlers[key]
if (!handler) {
return false
}
return handler(component)
} catch (error) {
console.error(error)
notifications.error("Error handling key press")
}
}
const handleKeyPress = async e => {
// Ignore repeating events
if (e.repeat) {
return
}
// Ignore events when typing
const activeTag = document.activeElement?.tagName.toLowerCase()
if (["input", "textarea"].indexOf(activeTag) !== -1 && e.key !== "Escape") {
return
}
// Key events are always for the selected component
return handleKeyAction($selectedComponent, e.key, e.ctrlKey || e.metaKey)
}
const handleComponentMenu = async e => {
// Menu events can be for any component
const { id, key, ctrlKey } = e.detail
const component = findComponent($selectedScreen.props, id)
return await handleKeyAction(component, key, ctrlKey)
}
onMount(() => {
document.addEventListener("keydown", handleKeyPress)
document.addEventListener("component-menu", handleComponentMenu)
return () => {
document.removeEventListener("keydown", handleKeyPress)
document.removeEventListener("component-menu", handleComponentMenu)
}
})
</script>
<ConfirmDialog
bind:this={confirmDeleteDialog}
title="Confirm Deletion"
body={`Are you sure you want to delete "${componentToDelete?._instanceName}"?`}
okText="Delete Component"
onOk={() => store.actions.components.delete(componentToDelete)}
/>

View file

@ -6,55 +6,11 @@
import { store, selectedScreen } from "builderStore"
import NavItem from "components/common/NavItem.svelte"
import ScreenslotDropdownMenu from "./ScreenslotDropdownMenu.svelte"
import { setContext } from "svelte"
import DNDPositionIndicator from "./DNDPositionIndicator.svelte"
import { DropPosition } from "./dndStore"
import { notifications, Button } from "@budibase/bbui"
let scrollRef
const scrollTo = bounds => {
if (!bounds) {
return
}
const sidebarWidth = 259
const navItemHeight = 32
const { scrollLeft, scrollTop, offsetHeight } = scrollRef
let scrollBounds = scrollRef.getBoundingClientRect()
let newOffsets = {}
// Calculate left offset
const offsetX = bounds.left + bounds.width + scrollLeft - 36
if (offsetX > sidebarWidth) {
newOffsets.left = offsetX - sidebarWidth
} else {
newOffsets.left = 0
}
if (newOffsets.left === scrollLeft) {
delete newOffsets.left
}
// Calculate top offset
const offsetY = bounds.top - scrollBounds?.top + scrollTop
if (offsetY > scrollTop + offsetHeight - 2 * navItemHeight) {
newOffsets.top = offsetY - offsetHeight + 2 * navItemHeight
} else if (offsetY < scrollTop + navItemHeight) {
newOffsets.top = offsetY - navItemHeight
} else {
delete newOffsets.top
}
// Skip if offset is unchanged
if (newOffsets.left == null && newOffsets.top == null) {
return
}
// Smoothly scroll to the offset
scrollRef.scroll({
...newOffsets,
behavior: "smooth",
})
}
import ComponentKeyHandler from "./ComponentKeyHandler.svelte"
import ComponentScrollWrapper from "./ComponentScrollWrapper.svelte"
const onDrop = async () => {
try {
@ -64,25 +20,15 @@
notifications.error("Error saving component")
}
}
// Set scroll context so components can invoke scrolling when selected
setContext("scroll", {
scrollTo,
})
</script>
<Panel title="Components" showExpandIcon borderRight>
<div class="add-component">
<Button on:click={() => $goto("./new")} cta>Add component</Button>
</div>
<div class="nav-items-container" bind:this={scrollRef}>
<ComponentScrollWrapper>
<ul>
<li
on:click={() => {
$store.selectedComponentId = $selectedScreen?.props._id
}}
id={`component-${$selectedScreen?.props._id}`}
>
<li>
<NavItem
text="Screen"
indentLevel={0}
@ -91,6 +37,10 @@
scrollable
icon="WebPage"
on:drop={onDrop}
on:click={() => {
$store.selectedComponentId = $selectedScreen?.props._id
}}
id={`component-${$selectedScreen?.props._id}`}
>
<ScreenslotDropdownMenu component={$selectedScreen?.props} />
</NavItem>
@ -114,8 +64,9 @@
{/if}
</li>
</ul>
</div>
</ComponentScrollWrapper>
</Panel>
<ComponentKeyHandler />
<style>
.add-component {
@ -125,12 +76,6 @@
flex-direction: column;
align-items: stretch;
}
.nav-items-container {
padding: var(--spacing-xl) 0;
flex: 1 1 auto;
overflow: auto;
height: 0;
}
ul {
list-style: none;
padding-left: 0;

View file

@ -0,0 +1,82 @@
<script>
import { setContext } from "svelte"
import { dndStore } from "./dndStore"
import { notifications } from "@budibase/bbui"
let scrollRef
const scrollTo = bounds => {
if (!bounds) {
return
}
const sidebarWidth = 259
const navItemHeight = 32
const { scrollLeft, scrollTop, offsetHeight } = scrollRef
let scrollBounds = scrollRef.getBoundingClientRect()
let newOffsets = {}
// Calculate left offset
const offsetX = bounds.left + bounds.width + scrollLeft - 36
if (offsetX > sidebarWidth) {
newOffsets.left = offsetX - sidebarWidth
} else {
newOffsets.left = 0
}
if (newOffsets.left === scrollLeft) {
delete newOffsets.left
}
// Calculate top offset
const offsetY = bounds.top - scrollBounds?.top + scrollTop
if (offsetY > scrollTop + offsetHeight - 2 * navItemHeight) {
newOffsets.top = offsetY - offsetHeight + 2 * navItemHeight
} else if (offsetY < scrollTop + navItemHeight) {
newOffsets.top = offsetY - navItemHeight
} else {
delete newOffsets.top
}
// Skip if offset is unchanged
if (newOffsets.left == null && newOffsets.top == null) {
return
}
// Smoothly scroll to the offset
scrollRef.scroll({
...newOffsets,
behavior: "smooth",
})
}
// Set scroll context so components can invoke scrolling when selected
setContext("scroll", {
scrollTo,
})
const onDrop = async () => {
try {
await dndStore.actions.drop()
} catch (error) {
console.error(error)
notifications.error("Error saving component")
}
}
</script>
<div
bind:this={scrollRef}
on:drop={onDrop}
ondragover="return false"
ondragenter="return false"
>
<slot />
</div>
<style>
div {
padding: var(--spacing-xl) 0;
flex: 1 1 auto;
overflow: auto;
height: 0;
}
</style>

View file

@ -68,7 +68,8 @@
closedNodes = closedNodes
}
const onDrop = async () => {
const onDrop = async e => {
e.stopPropagation()
try {
await dndStore.actions.drop()
} catch (error) {

View file

@ -31,15 +31,20 @@
<div slot="control" class="icon">
<Icon size="S" hoverable name="MoreSmallList" />
</div>
<MenuItem icon="Copy" on:click={() => storeComponentForCopy(false)}>
<MenuItem
icon="Copy"
keyBind="Ctrl+C"
on:click={() => storeComponentForCopy(false)}
>
Copy
</MenuItem>
<MenuItem
icon="ShowOneLayer"
icon="LayersSendToBack"
keyBind="Ctrl+V"
on:click={() => pasteComponent("inside")}
disabled={noPaste}
>
Paste inside
Paste
</MenuItem>
</ActionMenu>
{/if}

View file

@ -36,7 +36,12 @@
}
}
const canRenderControl = setting => {
const canRenderControl = (setting, isScreen) => {
// Prevent rendering on click setting for screens
if (setting?.type === "event" && isScreen) {
return false
}
const control = getComponentForSetting(setting)
if (!control) {
return false
@ -87,7 +92,7 @@
/>
{/if}
{#each section.settings as setting (setting.key)}
{#if canRenderControl(setting)}
{#if canRenderControl(setting, isScreen)}
<PropertyControl
type={setting.type}
control={getComponentForSetting(setting)}

View file

@ -1,20 +1,11 @@
<script>
import { notifications, Slider, Icon } from "@budibase/bbui"
import { notifications } from "@budibase/bbui"
import { store } from "builderStore"
import { Constants } from "@budibase/frontend-core"
const ThemeOptions = [
"spectrum--darkest",
"spectrum--dark",
"spectrum--light",
"spectrum--lightest",
]
$: themeIndex = ThemeOptions.indexOf($store.theme) ?? 2
const onChangeTheme = async e => {
const onChangeTheme = async theme => {
try {
const theme = ThemeOptions[e.detail] ?? ThemeOptions[2]
await store.actions.theme.save(theme)
await store.actions.theme.save(`spectrum--${theme}`)
} catch (error) {
notifications.error("Error updating theme")
}
@ -22,26 +13,52 @@
</script>
<div class="container">
<Icon name="Moon" />
<Slider
min={0}
max={3}
step={1}
value={themeIndex}
on:change={onChangeTheme}
/>
<Icon name="Light" />
{#each Constants.Themes as theme}
<div
class="theme"
class:selected={`spectrum--${theme.class}` === $store.theme}
on:click={() => onChangeTheme(theme.class)}
>
<div
style="background: {theme.preview}"
class="color spectrum--{theme.class}"
/>
{theme.name}
</div>
{/each}
</div>
<style>
div {
.container {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
gap: var(--spacing-xs);
}
.color {
width: 20px;
height: 20px;
border-radius: 50px;
background: var(--spectrum-global-color-gray-200);
border: 1px solid rgba(0, 0, 0, 0.1);
}
.theme {
border-radius: 4px;
padding: var(--spacing-s) var(--spacing-m);
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-m);
gap: var(--spacing-xl);
transition: background 130ms ease-out;
font-weight: 600;
color: var(--spectrum-global-color-gray-900);
}
div :global(.spectrum-Form-item) {
flex: 1 1 auto;
.theme:hover {
cursor: pointer;
}
.theme.selected,
.theme:hover {
background: var(--spectrum-global-color-gray-50);
}
</style>

View file

@ -0,0 +1,38 @@
<script>
import { createEventDispatcher } from "svelte"
import { Slider, Button } from "@budibase/bbui"
export let customTheme
const dispatch = createEventDispatcher()
const options = ["0", "4px", "8px", "16px"]
$: index = options.indexOf(customTheme.buttonBorderRadius) ?? 2
const onChange = async e => {
dispatch("change", options[e.detail])
}
</script>
<div class="container">
<Slider min={0} max={3} step={1} value={index} on:change={onChange} />
<div class="button" style="--radius: {customTheme.buttonBorderRadius};">
<Button primary newStyles>Button</Button>
</div>
</div>
<style>
.container {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-xl);
}
.container :global(.spectrum-Form-item) {
flex: 1 1 auto;
}
.button :global(.spectrum-Button) {
border-radius: var(--radius) !important;
}
</style>

View file

@ -1,35 +1,11 @@
<script>
import Panel from "components/design/Panel.svelte"
import {
Layout,
Label,
ColorPicker,
Button,
notifications,
} from "@budibase/bbui"
import { Layout, Label, ColorPicker, notifications } from "@budibase/bbui"
import { store } from "builderStore"
import { get } from "svelte/store"
import { DefaultAppTheme } from "constants"
import AppThemeSelect from "./AppThemeSelect.svelte"
const ButtonBorderRadiusOptions = [
{
label: "Square",
value: "0",
},
{
label: "Soft edge",
value: "4px",
},
{
label: "Curved",
value: "8px",
},
{
label: "Round",
value: "16px",
},
]
import ButtonRoundnessSelect from "./ButtonRoundnessSelect.svelte"
$: customTheme = $store.customTheme || {}
@ -52,22 +28,11 @@
<AppThemeSelect />
</Layout>
<Layout noPadding gap="XS">
<Label>Buttons</Label>
<div class="buttons">
{#each ButtonBorderRadiusOptions as option}
<div
class:active={customTheme.buttonBorderRadius === option.value}
style={`--radius: ${option.value}`}
>
<Button
secondary
on:click={() => update("buttonBorderRadius", option.value)}
>
{option.label}
</Button>
</div>
{/each}
</div>
<Label>Button roundness</Label>
<ButtonRoundnessSelect
{customTheme}
on:change={e => update("buttonBorderRadius", e.detail)}
/>
</Layout>
<Layout noPadding gap="XS">
<Label>Accent color</Label>
@ -88,29 +53,3 @@
</Layout>
</Layout>
</Panel>
<style>
.buttons {
display: grid;
grid-template-columns: 100px 100px;
gap: var(--spacing-m);
}
.buttons > div {
display: contents;
}
.buttons > div :global(.spectrum-Button) {
border-radius: var(--radius) !important;
border-width: 1px;
border-color: var(--spectrum-global-color-gray-400);
font-weight: 600;
}
.buttons > div:hover :global(.spectrum-Button) {
background: var(--spectrum-global-color-gray-700);
border-color: var(--spectrum-global-color-gray-700);
}
.buttons > div.active :global(.spectrum-Button) {
background: var(--spectrum-global-color-gray-200);
color: var(--spectrum-global-color-gray-800);
border-color: var(--spectrum-global-color-gray-600);
}
</style>

View file

@ -1,13 +1,16 @@
<script>
import { Layout, Heading, Body, Button, notifications } from "@budibase/bbui"
import { goto, params } from "@roxi/routify"
import { users } from "stores/portal"
import { users, organisation } from "stores/portal"
import PasswordRepeatInput from "components/common/users/PasswordRepeatInput.svelte"
import Logo from "assets/bb-emblem.svg"
import { onMount } from "svelte"
const inviteCode = $params["?code"]
let password, error
$: company = $organisation.company || "Budibase"
async function acceptInvite() {
try {
await users.acceptInvite(inviteCode, password)
@ -17,16 +20,24 @@
notifications.error(error.message)
}
}
onMount(async () => {
try {
await organisation.init()
} catch (error) {
notifications.error("Error getting org config")
}
})
</script>
<section>
<div class="container">
<Layout>
<img src={Logo} alt="logo" />
<img alt="logo" src={$organisation.logoUrl || Logo} />
<Layout gap="XS" justifyItems="center" noPadding>
<Heading size="M">Accept Invitation</Heading>
<Heading size="M">Invitation to {company}</Heading>
<Body textAlign="center" size="M">
Please enter a password to set up your user.
Please enter a password to get started.
</Body>
</Layout>
<PasswordRepeatInput bind:error bind:password />
@ -46,7 +57,7 @@
}
.container {
margin: 0 auto;
width: 260px;
width: 300px;
display: flex;
flex-direction: column;
justify-content: flex-start;

View file

@ -18,6 +18,8 @@
Body,
Select,
Toggle,
Tag,
Tags,
} from "@budibase/bbui"
import { onMount } from "svelte"
import { API } from "api"
@ -29,6 +31,8 @@
OIDC: "oidc",
}
const HasSpacesRegex = /[\\"\s]/
// Some older google configs contain a manually specified value - retain the functionality to edit the field
// When there is no value or we are in the cloud - prohibit editing the field, must use platform url to change
$: googleCallbackUrl = undefined
@ -145,7 +149,6 @@
async function save(docs) {
let calls = []
// Only if the user has provided an image, upload it
if (image) {
let data = new FormData()
@ -157,7 +160,6 @@
})
)
}
docs.forEach(element => {
// Delete unsupported fields
delete element.createdAt
@ -199,7 +201,6 @@
}
}
})
if (calls.length) {
Promise.all(calls)
.then(data => {
@ -215,6 +216,21 @@
}
}
let defaultScopes = ["profile", "email", "offline_access"]
const refreshScopes = idx => {
providers.oidc.config.configs[idx]["scopes"] =
providers.oidc.config.configs[idx]["scopes"]
}
let scopesFields = [
{
editing: true,
inputText: null,
error: null,
},
]
onMount(async () => {
try {
await organisation.init()
@ -276,7 +292,7 @@
if (!oidcDoc?._id) {
providers.oidc = {
type: ConfigTypes.OIDC,
config: { configs: [{ activated: true }] },
config: { configs: [{ activated: true, scopes: defaultScopes }] },
}
} else {
originalOidcDoc = cloneDeep(oidcDoc)
@ -345,6 +361,7 @@
size="s"
cta
on:click={() => save([providers.oidc])}
dataCy={"oidc-save"}
>
Save
</Button>
@ -362,6 +379,7 @@
bind:value={providers.oidc.config.configs[0][field.name]}
readonly={field.readonly}
placeholder={field.placeholder}
dataCy={field.name}
/>
</div>
{/each}
@ -392,15 +410,132 @@
<div class="form-row">
<Label size="L">Activated</Label>
<Toggle
dataCy={"oidc-active"}
text=""
bind:value={providers.oidc.config.configs[0].activated}
/>
</div>
</Layout>
<span class="advanced-config">
<Layout gap="XS" noPadding>
<Heading size="XS">
<div class="auth-scopes">
<div>Advanced</div>
<Button
secondary
newStyles
size="S"
on:click={() => {
providers.oidc.config.configs[0]["scopes"] = [...defaultScopes]
}}
dataCy={"restore-oidc-default-scopes"}
>
Restore Defaults
</Button>
</div>
</Heading>
<Body size="S">
Changes to your authentication scopes will only take effect when you
next log in. Please refer to your vendor documentation before
modification.
</Body>
<div class="auth-form">
<span class="add-new">
<Label size="L">{"Auth Scopes"}</Label>
<Input
dataCy={"new-scope-input"}
error={scopesFields[0].error}
placeholder={"New Scope"}
bind:value={scopesFields[0].inputText}
on:keyup={e => {
if (!scopesFields[0].inputText) {
scopesFields[0].error = null
}
if (
e.key === "Enter" ||
e.keyCode === 13 ||
e.code == "Space" ||
e.keyCode == 32
) {
let scopes = providers.oidc.config.configs[0]["scopes"]
? providers.oidc.config.configs[0]["scopes"]
: [...defaultScopes]
let update = scopesFields[0].inputText.trim()
if (HasSpacesRegex.test(update)) {
scopesFields[0].error =
"Auth scopes cannot contain spaces, double quotes or backslashes"
return
} else if (scopes.indexOf(update) > -1) {
scopesFields[0].error = "Auth scope already exists"
return
} else if (!update.length) {
scopesFields[0].inputText = null
scopesFields[0].error = null
return
} else {
scopesFields[0].error = null
scopes.push(update)
providers.oidc.config.configs[0]["scopes"] = scopes
scopesFields[0].inputText = null
}
}
}}
/>
</span>
<div class="tag-wrap">
<span />
<Tags>
<Tag closable={false}>openid</Tag>
{#each providers.oidc.config.configs[0]["scopes"] || [...defaultScopes] as tag, idx}
<Tag
closable={scopesFields[0].editing}
on:click={() => {
let idxScopes = providers.oidc.config.configs[0]["scopes"]
if (idxScopes.length == 1) {
idxScopes.pop()
} else {
idxScopes.splice(idx, 1)
refreshScopes(0)
}
}}
>
{tag}
</Tag>
{/each}
</Tags>
</div>
</div>
</Layout>
</span>
{/if}
</Layout>
<style>
.auth-scopes {
display: flex;
justify-content: space-between;
align-items: center;
}
.advanced-config :global(.spectrum-Tags-item) {
margin-left: 0px;
margin-top: var(--spacing-m);
margin-right: var(--spacing-m);
}
.auth-form > * {
display: grid;
grid-gap: var(--spacing-l);
grid-template-columns: 100px 1fr;
}
.advanced-config .auth-form .tag-wrap {
padding: 0px 5px 5px 0px;
}
.form-row {
display: grid;
grid-template-columns: 100px 1fr;

View file

@ -44,7 +44,11 @@
]
}
function validateInput(email, index) {
function validateInput(input, index) {
if (input.email) {
input.email = input.email.trim()
}
const email = input.email
if (email) {
const res = emailValidator(email)
if (res === true) {
@ -61,7 +65,7 @@
const onConfirm = () => {
let valid = true
userData.forEach((input, index) => {
valid = validateInput(input.email, index) && valid
valid = validateInput(input, index) && valid
})
if (!valid) {
return false
@ -95,7 +99,7 @@
bind:dropdownValue={input.role}
options={Constants.BudibaseRoleOptions}
error={input.error}
on:blur={() => validateInput(input.email, index)}
on:blur={() => validateInput(input, index)}
/>
</div>
<div class="icon">

View file

@ -0,0 +1,73 @@
<script>
import { Body, ModalContent, Table } from "@budibase/bbui"
import { onMount } from "svelte"
export let userData
export let deleteUsersResponse
let successCount
let failureCount
let title
let unsuccessfulUsers
let message
const setTitle = () => {
if (successCount) {
title = `${successCount} users deleted`
} else {
title = "Oops!"
}
}
const setMessage = () => {
if (successCount) {
message = "However there was a problem deleting some users."
} else {
message = "There was a problem deleting some users."
}
}
const setUsers = () => {
unsuccessfulUsers = deleteUsersResponse.unsuccessful.map(user => {
return {
email: user.email,
reason: user.reason,
}
})
}
onMount(() => {
successCount = deleteUsersResponse.successful.length
failureCount = deleteUsersResponse.unsuccessful.length
setTitle()
setMessage()
setUsers()
})
const schema = {
email: {},
reason: {},
}
</script>
<ModalContent
size="M"
{title}
confirmText="Close"
showCloseIcon={false}
showCancelButton={false}
>
<Body size="XS">
{message}
</Body>
<Table
{schema}
data={unsuccessfulUsers}
allowEditColumns={false}
allowEditRows={false}
allowSelectRows={false}
/>
</ModalContent>
<style>
</style>

View file

@ -62,7 +62,7 @@
csvString = e.target.result
files = fileArray
userEmails = csvString.split("\n")
userEmails = csvString.split(/\r?\n/)
})
reader.readAsText(fileArray[0])
}

View file

@ -0,0 +1,75 @@
<script>
import { Body, ModalContent, Table } from "@budibase/bbui"
import { onMount } from "svelte"
export let inviteUsersResponse
let hasSuccess
let hasFailure
let title
let failureMessage
let unsuccessfulUsers
const setTitle = () => {
if (hasSuccess) {
title = "Users invited!"
} else if (hasFailure) {
title = "Oops!"
}
}
const setFailureMessage = () => {
if (hasSuccess) {
failureMessage = "However there was a problem inviting some users."
} else {
failureMessage = "There was a problem inviting users."
}
}
const setUsers = () => {
unsuccessfulUsers = inviteUsersResponse.unsuccessful.map(user => {
return {
email: user.email,
reason: user.reason,
}
})
}
onMount(() => {
hasSuccess = inviteUsersResponse.successful.length
hasFailure = inviteUsersResponse.unsuccessful.length
setTitle()
setFailureMessage()
setUsers()
})
const failedSchema = {
email: {},
reason: {},
}
</script>
<ModalContent showCancelButton={false} {title} confirmText="Done">
{#if hasSuccess}
<Body size="XS">
Your users should now receive an email invite to get access to their
Budibase account
</Body>
{/if}
{#if hasFailure}
<Body size="XS">
{failureMessage}
</Body>
<Table
schema={failedSchema}
data={unsuccessfulUsers}
allowEditColumns={false}
allowEditRows={false}
allowSelectRows={false}
/>
{/if}
</ModalContent>
<style>
</style>

View file

@ -2,24 +2,78 @@
import { Body, ModalContent, Table, Icon } from "@budibase/bbui"
import PasswordCopyRenderer from "./PasswordCopyRenderer.svelte"
import { parseToCsv } from "helpers/data/utils"
import { onMount } from "svelte"
export let userData
export let createUsersResponse
$: mappedData = userData.map(user => {
return {
email: user.email,
password: user.password,
let hasSuccess
let hasFailure
let title
let failureMessage
let userDataIndex
let successfulUsers
let unsuccessfulUsers
const setTitle = () => {
if (hasSuccess) {
title = "Users created!"
} else if (hasFailure) {
title = "Oops!"
}
}
const setFailureMessage = () => {
if (hasSuccess) {
failureMessage = "However there was a problem creating some users."
} else {
failureMessage = "There was a problem creating some users."
}
}
const setUsers = () => {
userDataIndex = userData.reduce((prev, current) => {
prev[current.email] = current
return prev
}, {})
successfulUsers = createUsersResponse.successful.map(user => {
return {
email: user.email,
password: userDataIndex[user.email].password,
}
})
unsuccessfulUsers = createUsersResponse.unsuccessful.map(user => {
return {
email: user.email,
reason: user.reason,
}
})
}
onMount(() => {
hasSuccess = createUsersResponse.successful.length
hasFailure = createUsersResponse.unsuccessful.length
setTitle()
setFailureMessage()
setUsers()
})
const schema = {
const successSchema = {
email: {},
password: {},
}
const failedSchema = {
email: {},
reason: {},
}
const downloadCsvFile = () => {
const fileName = "passwords.csv"
const content = parseToCsv(["email", "password"], mappedData)
const content = parseToCsv(["email", "password"], successfulUsers)
download(fileName, content)
}
@ -42,36 +96,52 @@
</script>
<ModalContent
size="S"
title="Accounts created!"
size="M"
{title}
confirmText="Done"
showCancelButton={false}
cancelText="Cancel"
showCloseIcon={false}
>
<Body size="XS">
All your new users can be accessed through the autogenerated passwords. Take
note of these passwords or download the CSV file.
</Body>
{#if hasFailure}
<Body size="XS">
{failureMessage}
</Body>
<Table
schema={failedSchema}
data={unsuccessfulUsers}
allowEditColumns={false}
allowEditRows={false}
allowSelectRows={false}
/>
{/if}
{#if hasSuccess}
<Body size="XS">
All your new users can be accessed through the autogenerated passwords.
Take note of these passwords or download the CSV file.
</Body>
<div class="container" on:click={downloadCsvFile}>
<div class="inner">
<Icon name="Download" />
<div class="container" on:click={downloadCsvFile}>
<div class="inner">
<Icon name="Download" />
<div style="margin-left: var(--spacing-m)">
<Body size="XS">Passwords CSV</Body>
<div style="margin-left: var(--spacing-m)">
<Body size="XS">Passwords CSV</Body>
</div>
</div>
</div>
</div>
<Table
{schema}
data={mappedData}
allowEditColumns={false}
allowEditRows={false}
allowSelectRows={false}
customRenderers={[{ column: "password", component: PasswordCopyRenderer }]}
/>
<Table
schema={successSchema}
data={successfulUsers}
allowEditColumns={false}
allowEditRows={false}
allowSelectRows={false}
customRenderers={[
{ column: "password", component: PasswordCopyRenderer },
]}
/>
{/if}
</ModalContent>
<style>

View file

@ -7,7 +7,6 @@
Table,
Layout,
Modal,
ModalContent,
Search,
notifications,
Pagination,
@ -23,6 +22,8 @@
import { goto } from "@roxi/routify"
import OnboardingTypeModal from "./_components/OnboardingTypeModal.svelte"
import PasswordModal from "./_components/PasswordModal.svelte"
import InvitedModal from "./_components/InvitedModal.svelte"
import DeletionFailureModal from "./_components/DeletionFailureModal.svelte"
import ImportUsersModal from "./_components/ImportUsersModal.svelte"
import { createPaginationStore } from "helpers/pagination"
import { get } from "svelte/store"
@ -33,7 +34,8 @@
inviteConfirmationModal,
onboardingTypeModal,
passwordModal,
importUsersModal
importUsersModal,
deletionFailureModal
let pageInfo = createPaginationStore()
let prevEmail = undefined,
searchEmail = undefined
@ -55,6 +57,9 @@
apps: {},
}
$: userData = []
$: createUsersResponse = { successful: [], unsuccessful: [] }
$: deleteUsersResponse = { successful: [], unsuccessful: [] }
$: inviteUsersResponse = { successful: [], unsuccessful: [] }
$: page = $pageInfo.page
$: fetchUsers(page, searchEmail)
$: {
@ -92,8 +97,7 @@
admin: user.role === Constants.BudibaseRoles.Admin,
}))
try {
const res = await users.invite(payload)
notifications.success(res.message)
inviteUsersResponse = await users.invite(payload)
inviteConfirmationModal.show()
} catch (error) {
notifications.error("Error inviting user")
@ -116,8 +120,9 @@
newUsers.push(user)
}
if (!newUsers.length)
if (!newUsers.length) {
notifications.info("Duplicated! There is no new users to add.")
}
return { ...userData, users: newUsers }
}
@ -139,12 +144,14 @@
userData = await removingDuplicities({ groups, users })
if (!userData.users.length) return
return createUser()
return createUsers()
}
async function createUser() {
async function createUsers() {
try {
await users.create(await removingDuplicities(userData))
createUsersResponse = await users.create(
await removingDuplicities(userData)
)
notifications.success("Successfully created user")
await groups.actions.init()
passwordModal.show()
@ -157,7 +164,7 @@
if (onboardingType === "emailOnboarding") {
createUserFlow()
} else {
await createUser()
await createUsers()
}
}
@ -176,8 +183,15 @@
notifications.error("You cannot delete yourself")
return
}
await users.bulkDelete(ids)
notifications.success(`Successfully deleted ${selectedRows.length} rows`)
deleteUsersResponse = await users.bulkDelete(ids)
if (deleteUsersResponse.unsuccessful?.length) {
deletionFailureModal.show()
} else {
notifications.success(
`Successfully deleted ${selectedRows.length} users`
)
}
selectedRows = []
await fetchUsers(page, searchEmail)
} catch (error) {
@ -267,16 +281,7 @@
</Modal>
<Modal bind:this={inviteConfirmationModal}>
<ModalContent
showCancelButton={false}
title="Invites sent!"
confirmText="Done"
>
<Body size="S"
>Your users should now recieve an email invite to get access to their
Budibase account</Body
></ModalContent
>
<InvitedModal {inviteUsersResponse} />
</Modal>
<Modal bind:this={onboardingTypeModal}>
@ -284,7 +289,11 @@
</Modal>
<Modal bind:this={passwordModal}>
<PasswordModal userData={userData.users} />
<PasswordModal {createUsersResponse} userData={userData.users} />
</Modal>
<Modal bind:this={deletionFailureModal}>
<DeletionFailureModal {deleteUsersResponse} />
</Modal>
<Modal bind:this={importUsersModal}>

View file

@ -63,10 +63,14 @@ export function createUsersStore() {
return body
})
await API.createUsers({ users: mappedUsers, groups: data.groups })
const response = await API.createUsers({
users: mappedUsers,
groups: data.groups,
})
// re-search from first page
await search()
return response
}
async function del(id) {
@ -79,7 +83,7 @@ export function createUsersStore() {
}
async function bulkDelete(userIds) {
await API.deleteUsers(userIds)
return API.deleteUsers(userIds)
}
async function save(user) {

View file

@ -1,6 +1,6 @@
{
"name": "@budibase/cli",
"version": "1.2.58",
"version": "1.2.59-alpha.0",
"description": "Budibase CLI, for developers, self hosting and migrations.",
"main": "src/index.js",
"bin": {
@ -26,7 +26,7 @@
"outputPath": "build"
},
"dependencies": {
"@budibase/backend-core": "1.1.32-alpha.6",
"@budibase/backend-core": "1.2.58-alpha.7",
"axios": "0.21.2",
"chalk": "4.1.0",
"cli-progress": "3.11.2",

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