1
0
Fork 0
mirror of synced 2024-08-31 09:41:13 +12:00

Merge pull request #7899 from Budibase/develop

Develop -> Master (2.0)
This commit is contained in:
Martin McKeaveney 2022-09-26 18:47:15 +01:00 committed by GitHub
commit 4b5218e97f
217 changed files with 3457 additions and 4591 deletions

View file

@ -7,4 +7,5 @@ packages/server/client
packages/builder/.routify packages/builder/.routify
packages/builder/cypress/support/queryLevelTransformerFunction.js packages/builder/cypress/support/queryLevelTransformerFunction.js
packages/builder/cypress/support/queryLevelTransformerFunctionWithData.js packages/builder/cypress/support/queryLevelTransformerFunctionWithData.js
packages/builder/cypress/reports packages/builder/cypress/reports
packages/sdk/sdk

View file

@ -23,6 +23,15 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
services:
couchdb:
image: ibmcom/couchdb3
env:
COUCHDB_PASSWORD: budibase
COUCHDB_USER: budibase
ports:
- 4567:5984
strategy: strategy:
matrix: matrix:
node-version: [14.x] node-version: [14.x]
@ -53,13 +62,6 @@ jobs:
name: codecov-umbrella name: codecov-umbrella
verbose: true verbose: true
# TODO: parallelise this
- name: Cypress run
uses: cypress-io/github-action@v2
with:
install: false
command: yarn test:e2e:ci
- name: QA Core Integration Tests - name: QA Core Integration Tests
run: | run: |
cd qa-core cd qa-core

View file

@ -46,7 +46,8 @@ jobs:
- run: yarn - run: yarn
- run: yarn bootstrap - run: yarn bootstrap
- run: yarn lint - run: yarn lint
- run: yarn build - run: yarn build
- run: yarn build:sdk
- run: yarn test - run: yarn test
- name: Configure AWS Credentials - name: Configure AWS Credentials

View file

@ -56,6 +56,7 @@ jobs:
- run: yarn bootstrap - run: yarn bootstrap
- run: yarn lint - run: yarn lint
- run: yarn build - run: yarn build
- run: yarn build:sdk
- run: yarn test - run: yarn test
- name: Configure AWS Credentials - name: Configure AWS Credentials

View file

@ -8,4 +8,5 @@ packages/server/client
packages/server/src/definitions/openapi.ts packages/server/src/definitions/openapi.ts
packages/builder/.routify packages/builder/.routify
packages/builder/cypress/support/queryLevelTransformerFunction.js packages/builder/cypress/support/queryLevelTransformerFunction.js
packages/builder/cypress/support/queryLevelTransformerFunctionWithData.js packages/builder/cypress/support/queryLevelTransformerFunctionWithData.js
packages/sdk/sdk

View file

@ -76,6 +76,7 @@ affinity: {}
globals: globals:
appVersion: "latest" appVersion: "latest"
budibaseEnv: PRODUCTION budibaseEnv: PRODUCTION
tenantFeatureFlags: "*:LICENSING,*:USER_GROUPS"
enableAnalytics: "1" enableAnalytics: "1"
sentryDSN: "" sentryDSN: ""
posthogToken: "phc_bIjZL7oh2GEUd2vqvTBH8WvrX0fWTFQMs6H5KQxiUxU" posthogToken: "phc_bIjZL7oh2GEUd2vqvTBH8WvrX0fWTFQMs6H5KQxiUxU"

View file

@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
declare -a ENV_VARS=("COUCHDB_USER" "COUCHDB_PASSWORD" "DATA_DIR" "MINIO_ACCESS_KEY" "MINIO_SECRET_KEY" "INTERNAL_API_KEY" "JWT_SECRET" "REDIS_PASSWORD") declare -a ENV_VARS=("COUCHDB_USER" "COUCHDB_PASSWORD" "DATA_DIR" "MINIO_ACCESS_KEY" "MINIO_SECRET_KEY" "INTERNAL_API_KEY" "JWT_SECRET" "REDIS_PASSWORD")
declare -a DOCKER_VARS=("APP_PORT" "APPS_URL" "ARCHITECTURE" "BUDIBASE_ENVIRONMENT" "CLUSTER_PORT" "DEPLOYMENT_ENVIRONMENT" "MINIO_URL" "NODE_ENV" "POSTHOG_TOKEN" "REDIS_URL" "SELF_HOSTED" "WORKER_PORT" "WORKER_URL") declare -a DOCKER_VARS=("APP_PORT" "APPS_URL" "ARCHITECTURE" "BUDIBASE_ENVIRONMENT" "CLUSTER_PORT" "DEPLOYMENT_ENVIRONMENT" "MINIO_URL" "NODE_ENV" "POSTHOG_TOKEN" "REDIS_URL" "SELF_HOSTED" "WORKER_PORT" "WORKER_URL" "TENANT_FEATURE_FLAGS" "ACCOUNT_PORTAL_URL")
# Check the env vars set in Dockerfile have come through, AAS seems to drop them # Check the env vars set in Dockerfile have come through, AAS seems to drop them
[[ -z "${APP_PORT}" ]] && export APP_PORT=4001 [[ -z "${APP_PORT}" ]] && export APP_PORT=4001
[[ -z "${ARCHITECTURE}" ]] && export ARCHITECTURE=amd [[ -z "${ARCHITECTURE}" ]] && export ARCHITECTURE=amd
@ -10,6 +10,8 @@ declare -a DOCKER_VARS=("APP_PORT" "APPS_URL" "ARCHITECTURE" "BUDIBASE_ENVIRONME
[[ -z "${MINIO_URL}" ]] && export MINIO_URL=http://localhost:9000 [[ -z "${MINIO_URL}" ]] && export MINIO_URL=http://localhost:9000
[[ -z "${NODE_ENV}" ]] && export NODE_ENV=production [[ -z "${NODE_ENV}" ]] && export NODE_ENV=production
[[ -z "${POSTHOG_TOKEN}" ]] && export POSTHOG_TOKEN=phc_bIjZL7oh2GEUd2vqvTBH8WvrX0fWTFQMs6H5KQxiUxU [[ -z "${POSTHOG_TOKEN}" ]] && export POSTHOG_TOKEN=phc_bIjZL7oh2GEUd2vqvTBH8WvrX0fWTFQMs6H5KQxiUxU
[[ -z "${TENANT_FEATURE_FLAGS}" ]] && export TENANT_FEATURE_FLAGS="*:LICENSING,*:USER_GROUPS"
[[ -z "${ACCOUNT_PORTAL_URL}" ]] && export ACCOUNT_PORTAL_URL=https://account.budibase.app
[[ -z "${REDIS_URL}" ]] && export REDIS_URL=localhost:6379 [[ -z "${REDIS_URL}" ]] && export REDIS_URL=localhost:6379
[[ -z "${SELF_HOSTED}" ]] && export SELF_HOSTED=1 [[ -z "${SELF_HOSTED}" ]] && export SELF_HOSTED=1
[[ -z "${WORKER_PORT}" ]] && export WORKER_PORT=4002 [[ -z "${WORKER_PORT}" ]] && export WORKER_PORT=4002

View file

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

View file

@ -26,6 +26,7 @@
"bootstrap": "lerna bootstrap && lerna link && ./scripts/link-dependencies.sh", "bootstrap": "lerna bootstrap && lerna link && ./scripts/link-dependencies.sh",
"build": "lerna run build", "build": "lerna run build",
"build:dev": "lerna run prebuild && tsc --build --watch --preserveWatchOutput", "build:dev": "lerna run prebuild && tsc --build --watch --preserveWatchOutput",
"build:sdk": "lerna run build:sdk",
"deps:circular": "madge packages/server/dist/index.js packages/worker/src/index.ts packages/backend-core/dist/src/index.js packages/cli/src/index.js --circular", "deps:circular": "madge packages/server/dist/index.js packages/worker/src/index.ts packages/backend-core/dist/src/index.js packages/cli/src/index.js --circular",
"release": "lerna publish ${RELEASE_VERSION_TYPE:-patch} --yes --force-publish && yarn release:pro", "release": "lerna publish ${RELEASE_VERSION_TYPE:-patch} --yes --force-publish && yarn release:pro",
"release:develop": "lerna publish prerelease --yes --force-publish --dist-tag develop --exact && yarn release:pro:develop", "release:develop": "lerna publish prerelease --yes --force-publish --dist-tag develop --exact && yarn release:pro:develop",

View file

@ -1,6 +1,6 @@
{ {
"name": "@budibase/backend-core", "name": "@budibase/backend-core",
"version": "1.4.17", "version": "1.4.18-alpha.0",
"description": "Budibase backend core libraries used in server and worker", "description": "Budibase backend core libraries used in server and worker",
"main": "dist/src/index.js", "main": "dist/src/index.js",
"types": "dist/src/index.d.ts", "types": "dist/src/index.d.ts",
@ -20,7 +20,7 @@
"test:watch": "jest --watchAll" "test:watch": "jest --watchAll"
}, },
"dependencies": { "dependencies": {
"@budibase/types": "^1.4.17", "@budibase/types": "1.4.18-alpha.0",
"@shopify/jest-koa-mocks": "5.0.1", "@shopify/jest-koa-mocks": "5.0.1",
"@techpass/passport-openidconnect": "0.3.2", "@techpass/passport-openidconnect": "0.3.2",
"aws-sdk": "2.1030.0", "aws-sdk": "2.1030.0",

View file

@ -20,6 +20,7 @@ export enum ViewName {
AUTOMATION_LOGS = "automation_logs", AUTOMATION_LOGS = "automation_logs",
ACCOUNT_BY_EMAIL = "account_by_email", ACCOUNT_BY_EMAIL = "account_by_email",
PLATFORM_USERS_LOWERCASE = "platform_users_lowercase", PLATFORM_USERS_LOWERCASE = "platform_users_lowercase",
USER_BY_GROUP = "by_group_user",
} }
export const DeprecatedViews = { export const DeprecatedViews = {

View file

@ -36,154 +36,91 @@ async function removeDeprecated(db: PouchDB.Database, viewName: ViewName) {
} }
} }
export const createNewUserEmailView = async () => { export async function createView(db: any, viewJs: string, viewName: string) {
const db = getGlobalDB()
let designDoc let designDoc
try { try {
designDoc = await db.get(DESIGN_DB) designDoc = (await db.get(DESIGN_DB)) as DesignDocument
} catch (err) { } catch (err) {
// no design doc, make one // no design doc, make one
designDoc = DesignDoc() designDoc = DesignDoc()
} }
const view = { const view = {
// if using variables in a map function need to inject them before use map: viewJs,
map: `function(doc) {
if (doc._id.startsWith("${DocumentType.USER}${SEPARATOR}")) {
emit(doc.email.toLowerCase(), doc._id)
}
}`,
} }
designDoc.views = { designDoc.views = {
...designDoc.views, ...designDoc.views,
[ViewName.USER_BY_EMAIL]: view, [viewName]: view,
} }
await db.put(designDoc) await db.put(designDoc)
} }
export const createNewUserEmailView = async () => {
const db = getGlobalDB()
const viewJs = `function(doc) {
if (doc._id.startsWith("${DocumentType.USER}${SEPARATOR}")) {
emit(doc.email.toLowerCase(), doc._id)
}
}`
await createView(db, viewJs, ViewName.USER_BY_EMAIL)
}
export const createAccountEmailView = async () => { export const createAccountEmailView = async () => {
const viewJs = `function(doc) {
if (doc._id.startsWith("${DocumentType.ACCOUNT_METADATA}${SEPARATOR}")) {
emit(doc.email.toLowerCase(), doc._id)
}
}`
await doWithDB( await doWithDB(
StaticDatabases.PLATFORM_INFO.name, StaticDatabases.PLATFORM_INFO.name,
async (db: PouchDB.Database) => { async (db: PouchDB.Database) => {
let designDoc await createView(db, viewJs, ViewName.ACCOUNT_BY_EMAIL)
try {
designDoc = await db.get<DesignDocument>(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)
} }
) )
} }
export const createUserAppView = async () => { export const createUserAppView = async () => {
const db = getGlobalDB() as PouchDB.Database const db = getGlobalDB() as PouchDB.Database
let designDoc const viewJs = `function(doc) {
try { if (doc._id.startsWith("${DocumentType.USER}${SEPARATOR}") && doc.roles) {
designDoc = await db.get<DesignDocument>("_design/database") for (let prodAppId of Object.keys(doc.roles)) {
} catch (err) { let emitted = prodAppId + "${SEPARATOR}" + doc._id
// no design doc, make one emit(emitted, null)
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.USER}${SEPARATOR}") && doc.roles) {
for (let prodAppId of Object.keys(doc.roles)) {
let emitted = prodAppId + "${SEPARATOR}" + doc._id
emit(emitted, null)
}
} }
}`, }
} }`
designDoc.views = { await createView(db, viewJs, ViewName.USER_BY_APP)
...designDoc.views,
[ViewName.USER_BY_APP]: view,
}
await db.put(designDoc)
} }
export const createApiKeyView = async () => { export const createApiKeyView = async () => {
const db = getGlobalDB() const db = getGlobalDB()
let designDoc const viewJs = `function(doc) {
try { if (doc._id.startsWith("${DocumentType.DEV_INFO}") && doc.apiKey) {
designDoc = await db.get("_design/database") emit(doc.apiKey, doc.userId)
} catch (err) { }
designDoc = DesignDoc() }`
} await createView(db, viewJs, ViewName.BY_API_KEY)
const view = {
map: `function(doc) {
if (doc._id.startsWith("${DocumentType.DEV_INFO}") && doc.apiKey) {
emit(doc.apiKey, doc.userId)
}
}`,
}
designDoc.views = {
...designDoc.views,
[ViewName.BY_API_KEY]: view,
}
await db.put(designDoc)
} }
export const createUserBuildersView = async () => { export const createUserBuildersView = async () => {
const db = getGlobalDB() const db = getGlobalDB()
let designDoc const viewJs = `function(doc) {
try { if (doc.builder && doc.builder.global === true) {
designDoc = await db.get("_design/database") emit(doc._id, doc._id)
} catch (err) { }
// no design doc, make one }`
designDoc = DesignDoc() await createView(db, viewJs, ViewName.USER_BY_BUILDERS)
}
const view = {
map: `function(doc) {
if (doc.builder && doc.builder.global === true) {
emit(doc._id, doc._id)
}
}`,
}
designDoc.views = {
...designDoc.views,
[ViewName.USER_BY_BUILDERS]: view,
}
await db.put(designDoc)
} }
export const createPlatformUserView = async () => { export const createPlatformUserView = async () => {
const viewJs = `function(doc) {
if (doc.tenantId) {
emit(doc._id.toLowerCase(), doc._id)
}
}`
await doWithDB( await doWithDB(
StaticDatabases.PLATFORM_INFO.name, StaticDatabases.PLATFORM_INFO.name,
async (db: PouchDB.Database) => { async (db: PouchDB.Database) => {
let designDoc await createView(db, viewJs, ViewName.PLATFORM_USERS_LOWERCASE)
try {
designDoc = await db.get<DesignDocument>(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.tenantId) {
emit(doc._id.toLowerCase(), doc._id)
}
}`,
}
designDoc.views = {
...designDoc.views,
[ViewName.PLATFORM_USERS_LOWERCASE]: view,
}
await db.put(designDoc)
} }
) )
} }
@ -196,7 +133,7 @@ export const queryView = async <T>(
viewName: ViewName, viewName: ViewName,
params: PouchDB.Query.Options<T, T>, params: PouchDB.Query.Options<T, T>,
db: PouchDB.Database, db: PouchDB.Database,
CreateFuncByName: any, createFunc: any,
opts?: QueryViewOptions opts?: QueryViewOptions
): Promise<T[] | T | undefined> => { ): Promise<T[] | T | undefined> => {
try { try {
@ -213,10 +150,9 @@ export const queryView = async <T>(
} }
} catch (err: any) { } catch (err: any) {
if (err != null && err.name === "not_found") { if (err != null && err.name === "not_found") {
const createFunc = CreateFuncByName[viewName]
await removeDeprecated(db, viewName) await removeDeprecated(db, viewName)
await createFunc() await createFunc()
return queryView(viewName, params, db, CreateFuncByName, opts) return queryView(viewName, params, db, createFunc, opts)
} else { } else {
throw err throw err
} }
@ -228,7 +164,7 @@ export const queryPlatformView = async <T>(
params: PouchDB.Query.Options<T, T>, params: PouchDB.Query.Options<T, T>,
opts?: QueryViewOptions opts?: QueryViewOptions
): Promise<T[] | T | undefined> => { ): Promise<T[] | T | undefined> => {
const CreateFuncByName = { const CreateFuncByName: any = {
[ViewName.ACCOUNT_BY_EMAIL]: createAccountEmailView, [ViewName.ACCOUNT_BY_EMAIL]: createAccountEmailView,
[ViewName.PLATFORM_USERS_LOWERCASE]: createPlatformUserView, [ViewName.PLATFORM_USERS_LOWERCASE]: createPlatformUserView,
} }
@ -236,7 +172,8 @@ export const queryPlatformView = async <T>(
return doWithDB( return doWithDB(
StaticDatabases.PLATFORM_INFO.name, StaticDatabases.PLATFORM_INFO.name,
async (db: PouchDB.Database) => { async (db: PouchDB.Database) => {
return queryView(viewName, params, db, CreateFuncByName, opts) const createFn = CreateFuncByName[viewName]
return queryView(viewName, params, db, createFn, opts)
} }
) )
} }
@ -247,7 +184,7 @@ export const queryGlobalView = async <T>(
db?: PouchDB.Database, db?: PouchDB.Database,
opts?: QueryViewOptions opts?: QueryViewOptions
): Promise<T[] | T | undefined> => { ): Promise<T[] | T | undefined> => {
const CreateFuncByName = { const CreateFuncByName: any = {
[ViewName.USER_BY_EMAIL]: createNewUserEmailView, [ViewName.USER_BY_EMAIL]: createNewUserEmailView,
[ViewName.BY_API_KEY]: createApiKeyView, [ViewName.BY_API_KEY]: createApiKeyView,
[ViewName.USER_BY_BUILDERS]: createUserBuildersView, [ViewName.USER_BY_BUILDERS]: createUserBuildersView,
@ -257,5 +194,6 @@ export const queryGlobalView = async <T>(
if (!db) { if (!db) {
db = getGlobalDB() as PouchDB.Database db = getGlobalDB() as PouchDB.Database
} }
return queryView(viewName, params, db, CreateFuncByName, opts) const createFn = CreateFuncByName[viewName]
return queryView(viewName, params, db, createFn, opts)
} }

View file

@ -23,9 +23,11 @@ export default class LoggingProcessor implements EventProcessor {
return return
} }
let timestampString = getTimestampString(timestamp) let timestampString = getTimestampString(timestamp)
console.log( let message = `[audit] [tenant=${identity.tenantId}] [identityType=${identity.type}] [identity=${identity.id}] ${timestampString} ${event} `
`[audit] [tenant=${identity.tenantId}] [identityType=${identity.type}] [identity=${identity.id}] ${timestampString} ${event} ` if (env.isDev()) {
) message = message + `[debug: [properties=${JSON.stringify(properties)}] ]`
}
console.log(message)
} }
async identify(identity: Identity, timestamp?: string | number) { async identify(identity: Identity, timestamp?: string | number) {

View file

@ -40,9 +40,9 @@ export async function usersAdded(count: number, group: UserGroup) {
await publishEvent(Event.USER_GROUP_USERS_ADDED, properties) await publishEvent(Event.USER_GROUP_USERS_ADDED, properties)
} }
export async function usersDeleted(emails: string[], group: UserGroup) { export async function usersDeleted(count: number, group: UserGroup) {
const properties: GroupUsersDeletedEvent = { const properties: GroupUsersDeletedEvent = {
count: emails.length, count,
groupId: group._id as string, groupId: group._id as string,
} }
await publishEvent(Event.USER_GROUP_USERS_REMOVED, properties) await publishEvent(Event.USER_GROUP_USERS_REMOVED, properties)

View file

@ -1,27 +1,78 @@
import { publishEvent } from "../events" import { publishEvent } from "../events"
import { import {
Event, Event,
License,
LicenseActivatedEvent, LicenseActivatedEvent,
LicenseDowngradedEvent, LicensePlanChangedEvent,
LicenseUpdatedEvent, LicenseTierChangedEvent,
LicenseUpgradedEvent, PlanType,
Account,
LicensePortalOpenedEvent,
LicenseCheckoutSuccessEvent,
LicenseCheckoutOpenedEvent,
LicensePaymentFailedEvent,
LicensePaymentRecoveredEvent,
} from "@budibase/types" } from "@budibase/types"
// TODO export async function tierChanged(account: Account, from: number, to: number) {
export async function updgraded(license: License) { const properties: LicenseTierChangedEvent = {
const properties: LicenseUpgradedEvent = {} accountId: account.accountId,
await publishEvent(Event.LICENSE_UPGRADED, properties) to,
from,
}
await publishEvent(Event.LICENSE_TIER_CHANGED, properties)
} }
// TODO export async function planChanged(
export async function downgraded(license: License) { account: Account,
const properties: LicenseDowngradedEvent = {} from: PlanType,
await publishEvent(Event.LICENSE_DOWNGRADED, properties) to: PlanType
) {
const properties: LicensePlanChangedEvent = {
accountId: account.accountId,
to,
from,
}
await publishEvent(Event.LICENSE_PLAN_CHANGED, properties)
} }
// TODO export async function activated(account: Account) {
export async function activated(license: License) { const properties: LicenseActivatedEvent = {
const properties: LicenseActivatedEvent = {} accountId: account.accountId,
}
await publishEvent(Event.LICENSE_ACTIVATED, properties) await publishEvent(Event.LICENSE_ACTIVATED, properties)
} }
export async function checkoutOpened(account: Account) {
const properties: LicenseCheckoutOpenedEvent = {
accountId: account.accountId,
}
await publishEvent(Event.LICENSE_CHECKOUT_OPENED, properties)
}
export async function checkoutSuccess(account: Account) {
const properties: LicenseCheckoutSuccessEvent = {
accountId: account.accountId,
}
await publishEvent(Event.LICENSE_CHECKOUT_SUCCESS, properties)
}
export async function portalOpened(account: Account) {
const properties: LicensePortalOpenedEvent = {
accountId: account.accountId,
}
await publishEvent(Event.LICENSE_PORTAL_OPENED, properties)
}
export async function paymentFailed(account: Account) {
const properties: LicensePaymentFailedEvent = {
accountId: account.accountId,
}
await publishEvent(Event.LICENSE_PAYMENT_FAILED, properties)
}
export async function paymentRecovered(account: Account) {
const properties: LicensePaymentRecoveredEvent = {
accountId: account.accountId,
}
await publishEvent(Event.LICENSE_PAYMENT_RECOVERED, properties)
}

View file

@ -53,7 +53,7 @@ exports.getTenantFeatureFlags = tenantId => {
return flags return flags
} }
exports.FeatureFlag = { exports.TenantFeatureFlag = {
LICENSING: "LICENSING", LICENSING: "LICENSING",
GOOGLE_SHEETS: "GOOGLE_SHEETS", GOOGLE_SHEETS: "GOOGLE_SHEETS",
USER_GROUPS: "USER_GROUPS", USER_GROUPS: "USER_GROUPS",

View file

@ -18,6 +18,7 @@ import * as logging from "./logging"
import pino from "./pino" import pino from "./pino"
import * as middleware from "./middleware" import * as middleware from "./middleware"
import plugins from "./plugin" import plugins from "./plugin"
import encryption from "./security/encryption"
// mimic the outer package exports // mimic the outer package exports
import * as db from "./pkg/db" import * as db from "./pkg/db"
@ -60,6 +61,7 @@ const core = {
...pino, ...pino,
...errorClasses, ...errorClasses,
middleware, middleware,
encryption,
} }
export = core export = core

View file

@ -75,6 +75,15 @@ function validateDatasource(schema) {
}) })
.unknown(true) .unknown(true)
.required(), .required(),
extra: joi.object().pattern(
joi.string(),
joi.object({
type: joi.string().required(),
displayName: joi.string().required(),
required: joi.boolean(),
data: joi.object(),
})
),
}), }),
}) })
runJoi(validator, schema) runJoi(validator, schema)

View file

@ -78,7 +78,7 @@ function isBuiltin(role) {
*/ */
exports.builtinRoleToNumber = id => { exports.builtinRoleToNumber = id => {
const builtins = exports.getBuiltinRoles() const builtins = exports.getBuiltinRoles()
const MAX = Object.values(BUILTIN_IDS).length + 1 const MAX = Object.values(builtins).length + 1
if (id === BUILTIN_IDS.ADMIN || id === BUILTIN_IDS.BUILDER) { if (id === BUILTIN_IDS.ADMIN || id === BUILTIN_IDS.BUILDER) {
return MAX return MAX
} }
@ -94,6 +94,22 @@ exports.builtinRoleToNumber = id => {
return count return count
} }
/**
* Converts any role to a number, but has to be async to get the roles from db.
*/
exports.roleToNumber = async id => {
if (exports.isBuiltin(id)) {
return exports.builtinRoleToNumber(id)
}
const hierarchy = await exports.getUserRoleHierarchy(id)
for (let role of hierarchy) {
if (isBuiltin(role.inherits)) {
return exports.builtinRoleToNumber(role.inherits) + 1
}
}
return 0
}
/** /**
* Returns whichever builtin roleID is lower. * Returns whichever builtin roleID is lower.
*/ */
@ -172,7 +188,7 @@ async function getAllUserRoles(userRoleId) {
* to determine if a user can access something that requires a specific role. * to determine if a user can access something that requires a specific role.
* @param {string} userRoleId The user's role ID, this can be found in their access token. * @param {string} userRoleId The user's role ID, this can be found in their access token.
* @param {object} opts Various options, such as whether to only retrieve the IDs (default true). * @param {object} opts Various options, such as whether to only retrieve the IDs (default true).
* @returns {Promise<string[]>} returns an ordered array of the roles, with the first being their * @returns {Promise<string[]|object[]>} returns an ordered array of the roles, with the first being their
* highest level of access and the last being the lowest level. * highest level of access and the last being the lowest level.
*/ */
exports.getUserRoleHierarchy = async (userRoleId, opts = { idOnly: true }) => { exports.getUserRoleHierarchy = async (userRoleId, opts = { idOnly: true }) => {

View file

@ -121,7 +121,7 @@ export const getTenantUser = async (
return response return response
} }
export const isUserInAppTenant = (appId: string, user: any) => { export const isUserInAppTenant = (appId: string, user?: any) => {
let userTenantId let userTenantId
if (user) { if (user) {
userTenantId = user.tenantId || DEFAULT_TENANT_ID userTenantId = user.tenantId || DEFAULT_TENANT_ID

View file

@ -6,7 +6,24 @@ import {
} from "./db/utils" } from "./db/utils"
import { queryGlobalView } from "./db/views" import { queryGlobalView } from "./db/views"
import { UNICODE_MAX } from "./db/constants" import { UNICODE_MAX } from "./db/constants"
import { User } from "@budibase/types" import { BulkDocsResponse, User } from "@budibase/types"
import { getGlobalDB } from "./context"
import PouchDB from "pouchdb"
export const bulkGetGlobalUsersById = async (userIds: string[]) => {
const db = getGlobalDB() as PouchDB.Database
return (
await db.allDocs({
keys: userIds,
include_docs: true,
})
).rows.map(row => row.doc) as User[]
}
export const bulkUpdateGlobalUsers = async (users: User[]) => {
const db = getGlobalDB() as PouchDB.Database
return (await db.bulkDocs(users)) as BulkDocsResponse
}
/** /**
* Given an email address this will use a view to search through * Given an email address this will use a view to search through

View file

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

View file

@ -9,13 +9,13 @@
import StatusLight from "../../StatusLight/StatusLight.svelte" import StatusLight from "../../StatusLight/StatusLight.svelte"
import Detail from "../../Typography/Detail.svelte" import Detail from "../../Typography/Detail.svelte"
import Search from "./Search.svelte" import Search from "./Search.svelte"
import IconAvatar from "../../Icon/IconAvatar.svelte"
export let primaryLabel = "" export let primaryLabel = ""
export let primaryValue = null export let primaryValue = null
export let id = null export let id = null
export let placeholder = "Choose an option or type" export let placeholder = "Choose an option or type"
export let disabled = false export let disabled = false
export let updateOnChange = true
export let error = null export let error = null
export let secondaryOptions = [] export let secondaryOptions = []
export let primaryOptions = [] export let primaryOptions = []
@ -204,19 +204,11 @@
})} })}
> >
{#if primaryOptions[title].getIcon(option)} {#if primaryOptions[title].getIcon(option)}
<div <IconAvatar
style="background: {primaryOptions[title].getColour( size="S"
option icon={primaryOptions[title].getIcon(option)}
)};" background={primaryOptions[title].getColour(option)}
class="circle" />
>
<div>
<Icon
size="S"
name={primaryOptions[title].getIcon(option)}
/>
</div>
</div>
{:else if getPrimaryOptionColour(option, idx)} {:else if getPrimaryOptionColour(option, idx)}
<span class="option-left"> <span class="option-left">
<StatusLight <StatusLight
@ -226,12 +218,13 @@
</span> </span>
{/if} {/if}
<span class="spectrum-Menu-itemLabel"> <span class="spectrum-Menu-itemLabel">
<span <div
class="primary-text"
class:spacing-group={primaryOptions[title].getIcon(option)} class:spacing-group={primaryOptions[title].getIcon(option)}
> >
{primaryOptions[title].getLabel(option)} {primaryOptions[title].getLabel(option)}
<span /> <span />
</span> </div>
<svg <svg
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon" class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon"
focusable="false" focusable="false"
@ -335,6 +328,11 @@
</div> </div>
<style> <style>
.primary-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.spacing-group { .spacing-group {
margin-left: var(--spacing-m); margin-left: var(--spacing-m);
} }
@ -367,25 +365,6 @@
padding-left: 8px; padding-left: 8px;
} }
.circle {
border-radius: 50%;
height: 28px;
color: white;
font-weight: bold;
line-height: 48px;
font-size: 1.2em;
width: 28px;
position: relative;
}
.circle > div {
position: absolute;
text-decoration: none;
top: 50%;
left: 50%;
transform: translateX(-50%) translateY(-50%);
}
.iconPadding { .iconPadding {
position: absolute; position: absolute;
top: 50%; top: 50%;

View file

@ -14,7 +14,6 @@
export let disabled = false export let disabled = false
export let readonly = false export let readonly = false
export let error = null export let error = null
export let updateOnChange = true
export let getSecondaryOptionLabel = option => export let getSecondaryOptionLabel = option =>
extractProperty(option, "label") extractProperty(option, "label")
export let getSecondaryOptionValue = option => export let getSecondaryOptionValue = option =>
@ -100,7 +99,6 @@
{searchTerm} {searchTerm}
{autocomplete} {autocomplete}
{dataCy} {dataCy}
{updateOnChange}
{error} {error}
{disabled} {disabled}
{readonly} {readonly}

View file

@ -0,0 +1,58 @@
<script>
import Icon from "./Icon.svelte"
export let icon
export let background
export let color
export let size = "M"
</script>
<div
class="icon size--{size}"
style="background: {background || `transparent`};"
class:filled={!!background}
>
<Icon name={icon} color={background ? "white" : color} />
</div>
<style>
.icon {
width: 28px;
height: 28px;
display: grid;
place-items: center;
border-radius: 50%;
}
.icon :global(.spectrum-Icon) {
width: 22px;
height: 22px;
}
.icon.filled :global(.spectrum-Icon) {
width: 16px;
height: 16px;
}
.icon.size--S {
width: 22px;
height: 22px;
}
.icon.size--S :global(.spectrum-Icon) {
width: 16px;
height: 16px;
}
.icon.size--S.filled :global(.spectrum-Icon) {
width: 12px;
height: 12px;
}
.icon.size--L {
width: 40px;
height: 40px;
}
.icon.size--L :global(.spectrum-Icon) {
width: 28px;
height: 28px;
}
.icon.size--L.filled :global(.spectrum-Icon) {
width: 22px;
height: 22px;
}
</style>

View file

@ -1,11 +1,12 @@
<script> <script>
import Body from "../Typography/Body.svelte" import Body from "../Typography/Body.svelte"
import Icon from "../Icon/Icon.svelte" import IconAvatar from "../Icon/IconAvatar.svelte"
import Label from "../Label/Label.svelte" import Label from "../Label/Label.svelte"
import Avatar from "../Avatar/Avatar.svelte" import Avatar from "../Avatar/Avatar.svelte"
export let icon = null export let icon = null
export let iconBackground = null export let iconBackground = null
export let iconColor = null
export let avatar = false export let avatar = false
export let title = null export let title = null
export let subtitle = null export let subtitle = null
@ -17,9 +18,7 @@
<div class="list-item" class:hoverable on:click> <div class="list-item" class:hoverable on:click>
<div class="left"> <div class="left">
{#if icon} {#if icon}
<div class="icon" style="background: {iconBackground || `transparent`};"> <IconAvatar {icon} color={iconColor} background={iconBackground} />
<Icon name={icon} size="S" color={iconBackground ? "white" : null} />
</div>
{/if} {/if}
{#if avatar} {#if avatar}
<Avatar {initials} /> <Avatar {initials} />
@ -88,11 +87,4 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.icon {
width: var(--spectrum-alias-avatar-size-400);
height: var(--spectrum-alias-avatar-size-400);
display: grid;
place-items: center;
border-radius: 50%;
}
</style> </style>

View file

@ -79,7 +79,7 @@
{/if} {/if}
</h1> </h1>
{#if showDivider} {#if showDivider}
<Divider size="M" /> <Divider />
{/if} {/if}
{/if} {/if}

View file

@ -65,6 +65,7 @@
<style> <style>
.spectrum-Popover { .spectrum-Popover {
min-width: var(--spectrum-global-dimension-size-2000); min-width: var(--spectrum-global-dimension-size-2000);
border-color: var(--spectrum-global-color-gray-300);
} }
.spectrum-Popover.is-open.spectrum-Popover--withTip { .spectrum-Popover.is-open.spectrum-Popover--withTip {
margin-top: var(--spacing-xs); margin-top: var(--spacing-xs);

View file

@ -20,6 +20,7 @@ export { default as Button } from "./Button/Button.svelte"
export { default as ButtonGroup } from "./ButtonGroup/ButtonGroup.svelte" export { default as ButtonGroup } from "./ButtonGroup/ButtonGroup.svelte"
export { default as ClearButton } from "./ClearButton/ClearButton.svelte" export { default as ClearButton } from "./ClearButton/ClearButton.svelte"
export { default as Icon, directions } from "./Icon/Icon.svelte" export { default as Icon, directions } from "./Icon/Icon.svelte"
export { default as IconAvatar } from "./Icon/IconAvatar.svelte"
export { default as Toggle } from "./Form/Toggle.svelte" export { default as Toggle } from "./Form/Toggle.svelte"
export { default as RadioGroup } from "./Form/RadioGroup.svelte" export { default as RadioGroup } from "./Form/RadioGroup.svelte"
export { default as Checkbox } from "./Form/Checkbox.svelte" export { default as Checkbox } from "./Form/Checkbox.svelte"

View file

@ -1,16 +1,14 @@
const cypressConfig = require("../cypress.json") const cypressConfig = require("../cypress.json")
const path = require("path")
const tmpdir = path.join(require("os").tmpdir(), ".budibase")
// normal development system // normal development system
const SERVER_PORT = cypressConfig.env.PORT const SERVER_PORT = cypressConfig.env.PORT
const WORKER_PORT = cypressConfig.env.WORKER_PORT const WORKER_PORT = cypressConfig.env.WORKER_PORT
process.env.NODE_ENV = "cypress" if (!process.env.NODE_ENV) {
process.env.NODE_ENV = "cypress"
}
process.env.ENABLE_ANALYTICS = "0" process.env.ENABLE_ANALYTICS = "0"
process.env.JWT_SECRET = cypressConfig.env.JWT_SECRET process.env.JWT_SECRET = cypressConfig.env.JWT_SECRET
process.env.COUCH_URL = `leveldb://${tmpdir}/.data/`
process.env.SELF_HOSTED = 1 process.env.SELF_HOSTED = 1
process.env.WORKER_URL = `http://localhost:${WORKER_PORT}/` process.env.WORKER_URL = `http://localhost:${WORKER_PORT}/`
process.env.APPS_URL = `http://localhost:${SERVER_PORT}/` process.env.APPS_URL = `http://localhost:${SERVER_PORT}/`

View file

@ -402,8 +402,8 @@ Cypress.Commands.add("searchForApplication", appName => {
// Searches for the app // Searches for the app
cy.get(".filter").then(() => { cy.get(".filter").then(() => {
cy.get(".spectrum-Textfield").within(() => { cy.get(".spectrum-Textfield").within(() => {
cy.get("input").eq(0).clear() cy.get("input").eq(0).clear({ force: true })
cy.get("input").eq(0).type(appName) cy.get("input").eq(0).type(appName, { force: true })
}) })
}) })
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "@budibase/builder", "name": "@budibase/builder",
"version": "1.4.17", "version": "1.4.18-alpha.0",
"license": "GPL-3.0", "license": "GPL-3.0",
"private": true, "private": true,
"scripts": { "scripts": {
@ -71,10 +71,10 @@
} }
}, },
"dependencies": { "dependencies": {
"@budibase/bbui": "^1.4.17", "@budibase/bbui": "1.4.18-alpha.0",
"@budibase/client": "^1.4.17", "@budibase/client": "1.4.18-alpha.0",
"@budibase/frontend-core": "^1.4.17", "@budibase/frontend-core": "1.4.18-alpha.0",
"@budibase/string-templates": "^1.4.17", "@budibase/string-templates": "1.4.18-alpha.0",
"@sentry/browser": "5.19.1", "@sentry/browser": "5.19.1",
"@spectrum-css/page": "^3.0.1", "@spectrum-css/page": "^3.0.1",
"@spectrum-css/vars": "^3.0.1", "@spectrum-css/vars": "^3.0.1",

View file

@ -9,14 +9,14 @@ import {
import { store } from "builderStore" import { store } from "builderStore"
import { import {
queries as queriesStores, queries as queriesStores,
roles as rolesStore,
tables as tablesStore, tables as tablesStore,
roles as rolesStore,
} from "stores/backend" } from "stores/backend"
import { import {
makePropSafe,
isJSBinding,
decodeJSBinding, decodeJSBinding,
encodeJSBinding, encodeJSBinding,
isJSBinding,
makePropSafe,
} from "@budibase/string-templates" } from "@budibase/string-templates"
import { TableNames } from "../constants" import { TableNames } from "../constants"
import { JSONUtils } from "@budibase/frontend-core" import { JSONUtils } from "@budibase/frontend-core"
@ -71,17 +71,19 @@ export const getAuthBindings = () => {
runtime: `${safeUser}.${safeOAuth2}.${safeAccessToken}`, runtime: `${safeUser}.${safeOAuth2}.${safeAccessToken}`,
readable: `Current User.OAuthToken`, readable: `Current User.OAuthToken`,
key: "accessToken", key: "accessToken",
display: { name: "OAuthToken" },
}, },
] ]
bindings = Object.keys(authBindings).map(key => { bindings = authBindings.map(fieldBinding => {
const fieldBinding = authBindings[key]
return { return {
type: "context", type: "context",
runtimeBinding: fieldBinding.runtime, runtimeBinding: fieldBinding.runtime,
readableBinding: fieldBinding.readable, readableBinding: fieldBinding.readable,
fieldSchema: { type: "string", name: fieldBinding.key }, fieldSchema: { type: "string", name: fieldBinding.key },
providerId: "user", providerId: "user",
category: "Current User",
display: fieldBinding.display,
} }
}) })
return bindings return bindings
@ -93,7 +95,7 @@ export const getAuthBindings = () => {
* @param {string} prefix A contextual string prefix/path for a user readable binding * @param {string} prefix A contextual string prefix/path for a user readable binding
* @return {object[]} An array containing readable/runtime binding objects * @return {object[]} An array containing readable/runtime binding objects
*/ */
export const toBindingsArray = (valueMap, prefix) => { export const toBindingsArray = (valueMap, prefix, category) => {
if (!valueMap) { if (!valueMap) {
return [] return []
} }
@ -101,11 +103,20 @@ export const toBindingsArray = (valueMap, prefix) => {
if (!binding || !valueMap[binding]) { if (!binding || !valueMap[binding]) {
return acc return acc
} }
acc.push({
let config = {
type: "context", type: "context",
runtimeBinding: binding, runtimeBinding: binding,
readableBinding: `${prefix}.${binding}`, readableBinding: `${prefix}.${binding}`,
}) icon: "Brackets",
}
if (category) {
config.category = category
}
acc.push(config)
return acc return acc
}, []) }, [])
} }
@ -382,21 +393,25 @@ export const getUserBindings = () => {
const { schema } = getSchemaForTable(TableNames.USERS) const { schema } = getSchemaForTable(TableNames.USERS)
const keys = Object.keys(schema).sort() const keys = Object.keys(schema).sort()
const safeUser = makePropSafe("user") const safeUser = makePropSafe("user")
keys.forEach(key => {
bindings = keys.reduce((acc, key) => {
const fieldSchema = schema[key] const fieldSchema = schema[key]
bindings.push({ if (fieldSchema.type !== "link") {
type: "context", acc.push({
runtimeBinding: `${safeUser}.${makePropSafe(key)}`, type: "context",
readableBinding: `Current User.${key}`, runtimeBinding: `${safeUser}.${makePropSafe(key)}`,
// Field schema and provider are required to construct relationship readableBinding: `Current User.${key}`,
// datasource options, based on bindable properties // Field schema and provider are required to construct relationship
fieldSchema, // datasource options, based on bindable properties
providerId: "user", fieldSchema,
category: "Current User", providerId: "user",
icon: "User", category: "Current User",
display: fieldSchema, icon: "User",
}) })
}) }
return acc
}, [])
return bindings return bindings
} }

View file

@ -173,7 +173,7 @@
</Body> </Body>
</ConfirmDialog> </ConfirmDialog>
<Divider size="S" /> <Divider />
<div class="query-header"> <div class="query-header">
<Heading size="S">Tables</Heading> <Heading size="S">Tables</Heading>
<div class="table-buttons"> <div class="table-buttons">
@ -209,7 +209,7 @@
{:else} {:else}
<Body size="S"><i>No tables found.</i></Body> <Body size="S"><i>No tables found.</i></Body>
{/if} {/if}
<Divider size="S" /> <Divider />
<div class="query-header"> <div class="query-header">
<Heading size="S">Relationships</Heading> <Heading size="S">Relationships</Heading>
<Button primary on:click={() => openRelationshipModal()}> <Button primary on:click={() => openRelationshipModal()}>

View file

@ -37,7 +37,7 @@
} }
</script> </script>
<Divider size="S" /> <Divider />
<div class="section-header"> <div class="section-header">
<div class="badge"> <div class="badge">
<Heading size="S">Headers</Heading> <Heading size="S">Headers</Heading>
@ -61,7 +61,7 @@
</ActionButton> </ActionButton>
</div> </div>
<Divider size="S" /> <Divider />
<div class="section-header"> <div class="section-header">
<div class="badge"> <div class="badge">
<Heading size="S">Authentication</Heading> <Heading size="S">Authentication</Heading>
@ -73,7 +73,7 @@
</Body> </Body>
<RestAuthenticationBuilder bind:configs={datasource.config.authConfigs} /> <RestAuthenticationBuilder bind:configs={datasource.config.authConfigs} />
<Divider size="S" /> <Divider />
<div class="section-header"> <div class="section-header">
<div class="badge"> <div class="badge">
<Heading size="S">Variables</Heading> <Heading size="S">Variables</Heading>

View file

@ -30,13 +30,14 @@
background: var(--spectrum-alias-background-color-primary); background: var(--spectrum-alias-background-color-primary);
border-radius: var(--border-radius-s); border-radius: var(--border-radius-s);
overflow: hidden; overflow: hidden;
min-height: 150px; min-height: 170px;
} }
.dash-card-header { .dash-card-header {
padding: var(--spacing-xl) var(--spectrum-global-dimension-static-size-400); padding: var(--spacing-xl) var(--spectrum-global-dimension-static-size-400);
border-bottom: 1px solid var(--spectrum-global-color-gray-300); border-bottom: 1px solid var(--spectrum-global-color-gray-300);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
transition: background-color 130ms ease-out;
} }
.dash-card-body { .dash-card-body {
padding: var(--spacing-xl) calc(var(--spacing-xl) * 2); padding: var(--spacing-xl) calc(var(--spacing-xl) * 2);

View file

@ -1,13 +1,23 @@
<script> <script>
import { Select } from "@budibase/bbui" import { Select } from "@budibase/bbui"
import { roles } from "stores/backend" import { roles } from "stores/backend"
import { RoleUtils } from "@budibase/frontend-core" import { Constants, RoleUtils } from "@budibase/frontend-core"
export let value export let value
export let error export let error
export let placeholder = null export let placeholder = null
export let autoWidth = false export let autoWidth = false
export let quiet = false export let quiet = false
export let allowPublic = true
$: options = getOptions($roles, allowPublic)
const getOptions = (roles, allowPublic) => {
if (allowPublic) {
return roles
}
return roles.filter(role => role._id !== Constants.Roles.PUBLIC)
}
</script> </script>
<Select <Select
@ -15,7 +25,7 @@
{quiet} {quiet}
bind:value bind:value
on:change on:change
options={$roles} {options}
getOptionLabel={role => role.name} getOptionLabel={role => role.name}
getOptionValue={role => role._id} getOptionValue={role => role._id}
getOptionColour={role => RoleUtils.getRoleColour(role._id)} getOptionColour={role => RoleUtils.getRoleColour(role._id)}

View file

@ -106,12 +106,3 @@
{/if} {/if}
</ModalContent> </ModalContent>
</Modal> </Modal>
<style>
.icon-wrapper {
display: contents;
}
.icon-wrapper.highlight :global(svg) {
color: var(--spectrum-global-color-blue-600);
}
</style>

View file

@ -200,7 +200,7 @@
{/each} {/each}
</ul> </ul>
{#if views?.length} {#if views?.length}
<Divider size="S" /> <Divider />
<div class="title"> <div class="title">
<Heading size="XS">Views</Heading> <Heading size="XS">Views</Heading>
</div> </div>
@ -211,7 +211,7 @@
</ul> </ul>
{/if} {/if}
{#if queries?.length} {#if queries?.length}
<Divider size="S" /> <Divider />
<div class="title"> <div class="title">
<Heading size="XS">Queries</Heading> <Heading size="XS">Queries</Heading>
</div> </div>
@ -227,7 +227,7 @@
</ul> </ul>
{/if} {/if}
{#if links?.length} {#if links?.length}
<Divider size="S" /> <Divider />
<div class="title"> <div class="title">
<Heading size="XS">Relationships</Heading> <Heading size="XS">Relationships</Heading>
</div> </div>
@ -238,7 +238,7 @@
</ul> </ul>
{/if} {/if}
{#if fields?.length} {#if fields?.length}
<Divider size="S" /> <Divider />
<div class="title"> <div class="title">
<Heading size="XS">Fields</Heading> <Heading size="XS">Fields</Heading>
</div> </div>
@ -249,7 +249,7 @@
</ul> </ul>
{/if} {/if}
{#if jsonArrays?.length} {#if jsonArrays?.length}
<Divider size="S" /> <Divider />
<div class="title"> <div class="title">
<Heading size="XS">JSON Arrays</Heading> <Heading size="XS">JSON Arrays</Heading>
</div> </div>
@ -260,7 +260,7 @@
</ul> </ul>
{/if} {/if}
{#if showDataProviders && dataProviders?.length} {#if showDataProviders && dataProviders?.length}
<Divider size="S" /> <Divider />
<div class="title"> <div class="title">
<Heading size="XS">Data Providers</Heading> <Heading size="XS">Data Providers</Heading>
</div> </div>
@ -276,7 +276,7 @@
</ul> </ul>
{/if} {/if}
{#if otherSources?.length} {#if otherSources?.length}
<Divider size="S" /> <Divider />
<div class="title"> <div class="title">
<Heading size="XS">Other</Heading> <Heading size="XS">Other</Heading>
</div> </div>

View file

@ -8,7 +8,7 @@
let accountDowngradeModal let accountDowngradeModal
$: accountUrl = $admin.accountPortalUrl $: accountUrl = $admin.accountPortalUrl
$: upgradeUrl = `${accountUrl}/portal/upgrade` $: billingUrl = `${accountUrl}/portal/billing`
export function show() { export function show() {
accountDowngradeModal.show() accountDowngradeModal.show()
@ -24,10 +24,10 @@
title="Your account is now on the Free plan" title="Your account is now on the Free plan"
size="M" size="M"
showCancelButton={$auth.user.accountPortalAccess} showCancelButton={$auth.user.accountPortalAccess}
confirmText={$auth.user.accountPortalAccess ? "Upgrade" : "Confirm"} confirmText={$auth.user.accountPortalAccess ? "Billing" : "Confirm"}
onConfirm={$auth.user.accountPortalAccess onConfirm={$auth.user.accountPortalAccess
? () => { ? () => {
window.location.href = upgradeUrl window.location.href = billingUrl
} }
: null} : null}
> >
@ -35,7 +35,9 @@
The payment for your subscription has failed and we have downgraded your The payment for your subscription has failed and we have downgraded your
account to the <span class="free-plan">Free plan</span>. account to the <span class="free-plan">Free plan</span>.
</Body> </Body>
<Body>Upgrade to restore full functionality.</Body> <Body>
Please update your billing details to restore full functionality.
</Body>
{#if !$auth.user.accountPortalAccess} {#if !$auth.user.accountPortalAccess}
<Body>Please contact the account holder to upgrade.</Body> <Body>Please contact the account holder to upgrade.</Body>
{/if} {/if}

View file

@ -12,7 +12,10 @@
$: daysRemaining = $licensing.quotaResetDaysRemaining $: daysRemaining = $licensing.quotaResetDaysRemaining
$: quotaResetDate = $licensing.quotaResetDate $: quotaResetDate = $licensing.quotaResetDate
$: dayPassesUsed = $licensing.usageMetrics?.dayPasses $: dayPassesUsed =
$licensing.usageMetrics?.dayPasses > 100
? 100
: $licensing.usageMetrics?.dayPasses
$: dayPassesTitle = $: dayPassesTitle =
dayPassesUsed >= 100 dayPassesUsed >= 100
? "You have run out of Day Passes" ? "You have run out of Day Passes"

View file

@ -0,0 +1,31 @@
<script>
import { Modal, ModalContent, Body } from "@budibase/bbui"
let modal
export let onConfirm
export function show() {
modal.show()
}
export function hide() {
modal.hide()
}
</script>
<Modal bind:this={modal} on:hide={modal}>
<ModalContent
title="Confirm deletion"
size="S"
showCancelButton={true}
confirmText={"Confirm"}
{onConfirm}
>
<Body size="S">Are you sure you want to delete this license key?</Body>
<Body size="S">This license key cannot be re-activated.</Body>
</ModalContent>
</Modal>
<style>
</style>

View file

@ -8,7 +8,7 @@
import { ExpiringKeys } from "./constants" import { ExpiringKeys } from "./constants"
import { getBanners } from "./licensingBanners" import { getBanners } from "./licensingBanners"
import { banner } from "@budibase/bbui" import { banner } from "@budibase/bbui"
import { FEATURE_FLAGS, isEnabled } from "../../../helpers/featureFlags" import { TENANT_FEATURE_FLAGS, isEnabled } from "helpers/featureFlags"
const oneDayInSeconds = 86400 const oneDayInSeconds = 86400
@ -18,8 +18,8 @@
let paymentFailedModal let paymentFailedModal
let accountDowngradeModal let accountDowngradeModal
let userLoaded = false let userLoaded = false
let loaded = false
let licensingLoaded = false let licensingLoaded = false
let domLoaded = false
let currentModalCfg = null let currentModalCfg = null
const processModals = () => { const processModals = () => {
@ -43,7 +43,7 @@
{ {
key: ExpiringKeys.LICENSING_PAYMENT_FAILED, key: ExpiringKeys.LICENSING_PAYMENT_FAILED,
criteria: () => { criteria: () => {
return $licensing.accountPastDue && !$licensing.isFreePlan() return $licensing.accountPastDue && !$licensing.isFreePlan
}, },
action: () => { action: () => {
paymentFailedModal.show() paymentFailedModal.show()
@ -82,12 +82,18 @@
} }
} }
$: if (!userLoaded && $auth.user) {
userLoaded = true
}
$: if ( $: if (
userLoaded && userLoaded &&
licensingLoaded && $licensing.usageMetrics &&
loaded && domLoaded &&
isEnabled(FEATURE_FLAGS.LICENSING) !licensingLoaded &&
isEnabled(TENANT_FEATURE_FLAGS.LICENSING)
) { ) {
licensingLoaded = true
queuedModals = processModals() queuedModals = processModals()
queuedBanners = getBanners() queuedBanners = getBanners()
showNextModal() showNextModal()
@ -95,18 +101,7 @@
} }
onMount(async () => { onMount(async () => {
auth.subscribe(state => { domLoaded = true
if (state.user && !userLoaded) {
userLoaded = true
}
})
licensing.subscribe(state => {
if (state.usageMetrics && !licensingLoaded) {
licensingLoaded = true
}
})
loaded = true
}) })
</script> </script>

View file

@ -11,7 +11,7 @@
const paymentFailedTitle = "Payment failed" const paymentFailedTitle = "Payment failed"
$: accountUrl = $admin.accountPortalUrl $: accountUrl = $admin.accountPortalUrl
$: upgradeUrl = `${accountUrl}/portal/upgrade` $: billingUrl = `${accountUrl}/portal/billing`
export function show() { export function show() {
paymentFailedModal.show() paymentFailedModal.show()
@ -33,14 +33,14 @@
<ModalContent <ModalContent
title={paymentFailedTitle} title={paymentFailedTitle}
size="M" size="M"
confirmText="Upgrade" confirmText="Billing"
onConfirm={() => { onConfirm={() => {
window.location.href = upgradeUrl window.location.href = billingUrl
}} }}
> >
<Body>The payment for your subscription has failed</Body> <Body>The payment for your subscription has failed</Body>
<Body> <Body>
Please upgrade your billing details before your account gets downgraded Please update your billing details before your account gets downgraded
to the free plan to the free plan
</Body> </Body>
<Body weight={800}> <Body weight={800}>

View file

@ -10,15 +10,31 @@ const defaultCacheFn = key => {
temporalStore.actions.setExpiring(key, {}, oneDayInSeconds) temporalStore.actions.setExpiring(key, {}, oneDayInSeconds)
} }
const defaultAction = key => { const upgradeAction = key => {
return defaultNavigateAction(
key,
"Upgrade Plan",
`${get(admin).accountPortalUrl}/portal/upgrade`
)
}
const billingAction = key => {
return defaultNavigateAction(
key,
"Billing",
`${get(admin).accountPortalUrl}/portal/billing`
)
}
const defaultNavigateAction = (key, actionText, url) => {
if (!get(auth).user.accountPortalAccess) { if (!get(auth).user.accountPortalAccess) {
return {} return {}
} }
return { return {
extraButtonText: "Upgrade Plan", extraButtonText: actionText,
extraButtonAction: () => { extraButtonAction: () => {
defaultCacheFn(key) defaultCacheFn(key)
window.location.href = `${get(admin).accountPortalUrl}/portal/upgrade` window.location.href = url
}, },
} }
} }
@ -65,7 +81,7 @@ const buildUsageInfoBanner = (
? bannerConfig ? bannerConfig
: { : {
...bannerConfig, ...bannerConfig,
...defaultAction(cacheKey), ...upgradeAction(cacheKey),
} }
} }
@ -84,7 +100,7 @@ const buildDayPassBanner = () => {
? "" ? ""
: "Please contact your account holder to upgrade." : "Please contact your account holder to upgrade."
}`, }`,
...defaultAction(), ...upgradeAction(),
showCloseButton: false, showCloseButton: false,
} }
} }
@ -113,13 +129,13 @@ const buildPaymentFailedBanner = () => {
key: "payment_Failed", key: "payment_Failed",
type: BANNER_TYPES.NEGATIVE, type: BANNER_TYPES.NEGATIVE,
criteria: () => { criteria: () => {
return get(licensing)?.accountPastDue && !get(licensing).isFreePlan() return get(licensing)?.accountPastDue && !get(licensing).isFreePlan
}, },
message: `Payment Failed - Please update your billing details or your account will be downgrades in message: `Payment Failed - Please update your billing details or your account will be downgraded in
${get(licensing)?.pastDueDaysRemaining} day${ ${get(licensing)?.pastDueDaysRemaining} day${
get(licensing)?.pastDueDaysRemaining == 1 ? "" : "s" get(licensing)?.pastDueDaysRemaining == 1 ? "" : "s"
}`, }`,
...defaultAction(), ...billingAction(),
showCloseButton: false, showCloseButton: false,
tooltip: get(licensing).pastDueEndDate, tooltip: get(licensing).pastDueEndDate,
} }

View file

@ -1,75 +1,116 @@
<script> <script>
import { ActionButton, Icon, Search, Divider, Detail } from "@budibase/bbui" import { Icon, Search, Layout } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
export let searchTerm = "" export let searchTerm = ""
export let selected export let selected
export let filtered = [] export let list = []
export let addAll export let labelKey
export let select export let iconComponent = null
export let title export let extractIconProps = x => x
export let key
const dispatch = createEventDispatcher()
$: enrichedList = enrich(list, selected)
$: sortedList = sort(enrichedList)
const enrich = (list, selected) => {
return list.map(item => {
return {
...item,
selected: selected.find(x => x === item._id) != null,
}
})
}
const sort = list => {
let sortedList = list.slice()
sortedList.sort((a, b) => {
if (a.selected === b.selected) {
return a[labelKey] < b[labelKey] ? -1 : 1
} else if (a.selected) {
return -1
} else if (b.selected) {
return 1
}
return 0
})
return sortedList
}
</script> </script>
<div style="padding: var(--spacing-m)"> <div class="container">
<Search placeholder="Search" bind:value={searchTerm} /> <Layout gap="S">
<div class="header sub-header"> <div class="header">
<div> <Search placeholder="Search" bind:value={searchTerm} />
<Detail
>{filtered.length} {title}{filtered.length === 1 ? "" : "s"}</Detail
>
</div> </div>
<div> <div class="items">
<ActionButton on:click={addAll} emphasized size="S">Add all</ActionButton> {#each sortedList as item}
</div> <div
</div> on:click={() => {
<Divider noMargin /> dispatch(item.selected ? "deselect" : "select", item._id)
<div> }}
{#each filtered as item} class="item"
<div >
on:click={() => { {#if iconComponent}
select(item._id) <svelte:component
}} this={iconComponent}
style="padding-bottom: var(--spacing-m)" {...extractIconProps(item)}
class="selection"
>
<div>
{item[key]}
</div>
{#if selected.includes(item._id)}
<div>
<Icon
color="var(--spectrum-global-color-blue-600);"
name="Checkmark"
/> />
{/if}
<div class="text">
{item[labelKey]}
</div> </div>
{/if} {#if item.selected}
</div> <div>
{/each} <Icon
</div> color="var(--spectrum-global-color-blue-600);"
name="Checkmark"
/>
</div>
{/if}
</div>
{/each}
</div>
</Layout>
</div> </div>
<style> <style>
.container {
width: 280px;
}
.header { .header {
align-items: center; align-items: center;
padding: var(--spacing-m) 0 var(--spacing-m) 0; display: grid;
display: flex; gap: var(--spacing-m);
justify-content: space-between; grid-template-columns: 1fr;
} }
.items {
.selection { max-height: 242px;
align-items: end; overflow: auto;
overflow-x: hidden;
margin: 0 calc(-1 * var(--spacing-m));
margin-top: -8px;
}
.item {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
cursor: pointer; cursor: pointer;
padding: var(--spacing-s) var(--spacing-l);
background: var(--spectrum-global-color-gray-50);
transition: background 130ms ease-out;
gap: var(--spacing-m);
align-items: center;
} }
.item:hover {
.selection > :first-child { background: var(--spectrum-global-color-gray-100);
padding-top: var(--spacing-m); cursor: pointer;
} }
.text {
.sub-header { flex: 1 1 auto;
display: flex; width: 0;
justify-content: space-between; overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
</style> </style>

View file

@ -1,15 +1,12 @@
import { auth } from "../stores/portal" import { auth } from "../stores/portal"
import { get } from "svelte/store" import { get } from "svelte/store"
export const FEATURE_FLAGS = { export const TENANT_FEATURE_FLAGS = {
LICENSING: "LICENSING", LICENSING: "LICENSING",
USER_GROUPS: "USER_GROUPS", USER_GROUPS: "USER_GROUPS",
} }
export const isEnabled = featureFlag => { export const isEnabled = featureFlag => {
const user = get(auth).user const user = get(auth).user
if (user?.featureFlags?.includes(featureFlag)) { return !!user?.featureFlags?.includes(featureFlag)
return true
}
return false
} }

View file

@ -98,7 +98,7 @@
</header> </header>
<Body size="M">{integration.description}</Body> <Body size="M">{integration.description}</Body>
</Layout> </Layout>
<Divider size="S" /> <Divider />
<div class="config-header"> <div class="config-header">
<Heading size="S">Configuration</Heading> <Heading size="S">Configuration</Heading>
<Button disabled={!changed} cta on:click={saveDatasource}>Save</Button> <Button disabled={!changed} cta on:click={saveDatasource}>Save</Button>
@ -111,7 +111,7 @@
{#if datasource.plus} {#if datasource.plus}
<PlusConfigForm bind:datasource save={saveDatasource} /> <PlusConfigForm bind:datasource save={saveDatasource} />
{/if} {/if}
<Divider size="S" /> <Divider />
<div class="query-header"> <div class="query-header">
<Heading size="S">Queries</Heading> <Heading size="S">Queries</Heading>
<div class="query-buttons"> <div class="query-buttons">

View file

@ -60,14 +60,20 @@
$: staticVariables = datasource?.config?.staticVariables || {} $: staticVariables = datasource?.config?.staticVariables || {}
$: customRequestBindings = toBindingsArray(requestBindings, "Binding") $: customRequestBindings = toBindingsArray(
requestBindings,
"Binding",
"Bindings"
)
$: globalDynamicRequestBindings = toBindingsArray( $: globalDynamicRequestBindings = toBindingsArray(
globalDynamicBindings, globalDynamicBindings,
"Dynamic",
"Dynamic" "Dynamic"
) )
$: dataSourceStaticBindings = toBindingsArray( $: dataSourceStaticBindings = toBindingsArray(
staticVariables, staticVariables,
"Datasource.Static" "Datasource.Static",
"Datasource Static"
) )
$: mergedBindings = [ $: mergedBindings = [
@ -586,7 +592,7 @@
</div> </div>
<div class="bottom"> <div class="bottom">
<Layout paddingY="S" gap="S"> <Layout paddingY="S" gap="S">
<Divider size="S" /> <Divider />
{#if !response && Object.keys(schema).length === 0} {#if !response && Object.keys(schema).length === 0}
<Heading size="M">Response</Heading> <Heading size="M">Response</Heading>
<div class="placeholder"> <div class="placeholder">

View file

@ -56,7 +56,11 @@
] ]
let dragDisabled = true let dragDisabled = true
$: settings = getComponentSettings($selectedComponent?._component) $: settings = getComponentSettings($selectedComponent?._component)?.concat({
label: "Custom CSS",
key: "_css",
type: "text",
})
$: settingOptions = settings.map(setting => ({ $: settingOptions = settings.map(setting => ({
label: setting.label, label: setting.label,
value: setting.key, value: setting.key,

View file

@ -1,30 +1,41 @@
<script> <script>
import { import {
TextArea,
DetailSummary, DetailSummary,
ActionButton, ActionButton,
Drawer, Drawer,
DrawerContent,
Layout,
Body,
Button, Button,
notifications, notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import { store } from "builderStore" import { selectedScreen, store } from "builderStore"
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
import {
getBindableProperties,
readableToRuntimeBinding,
runtimeToReadableBinding,
} from "builderStore/dataBinding"
export let componentInstance export let componentInstance
let tempValue let tempValue
let drawer let drawer
$: bindings = getBindableProperties(
$selectedScreen,
$store.selectedComponentId
)
const openDrawer = () => { const openDrawer = () => {
tempValue = componentInstance?._styles?.custom tempValue = runtimeToReadableBinding(
bindings,
componentInstance?._styles?.custom
)
drawer.show() drawer.show()
} }
const save = async () => { const save = async () => {
try { try {
await store.actions.components.updateCustomStyle(tempValue) const value = readableToRuntimeBinding(bindings, tempValue)
await store.actions.components.updateCustomStyle(value)
} catch (error) { } catch (error) {
notifications.error("Error updating custom style") notifications.error("Error updating custom style")
} }
@ -42,26 +53,17 @@
</DetailSummary> </DetailSummary>
{#key componentInstance?._id} {#key componentInstance?._id}
<Drawer bind:this={drawer} title="Custom CSS"> <Drawer bind:this={drawer} title="Custom CSS">
<svelte:fragment slot="description">
Custom CSS overrides all other component styles.
</svelte:fragment>
<Button cta slot="buttons" on:click={save}>Save</Button> <Button cta slot="buttons" on:click={save}>Save</Button>
<DrawerContent slot="body"> <svelte:component
<div class="content"> this={ClientBindingPanel}
<Layout gap="S" noPadding> slot="body"
<Body size="S">Custom CSS overrides all other component styles.</Body> value={tempValue}
<TextArea bind:value={tempValue} placeholder="Enter some CSS..." /> on:change={event => (tempValue = event.detail)}
</Layout> allowJS
</div> {bindings}
</DrawerContent> />
</Drawer> </Drawer>
{/key} {/key}
<style>
.content {
max-width: 800px;
margin: 0 auto;
}
.content :global(textarea) {
font-family: monospace;
min-height: 240px !important;
font-size: var(--font-size-s);
}
</style>

View file

@ -59,7 +59,6 @@
// Use the currently selected role // Use the currently selected role
if (!screenAccessRole) { if (!screenAccessRole) {
console.log("NO ROLE")
return return
} }
screen.routing.roleId = screenAccessRole screen.routing.roleId = screenAccessRole

View file

@ -52,7 +52,8 @@
? publishedApps ? publishedApps
: publishedApps.filter(app => { : publishedApps.filter(app => {
return userGroups.find(group => { return userGroups.find(group => {
return Object.keys(group.roles) return groups.actions
.getGroupAppIds(group)
.map(role => apps.extractAppId(role)) .map(role => apps.extractAppId(role))
.includes(app.appId) .includes(app.appId)
}) })

View file

@ -19,7 +19,7 @@
import ChangePasswordModal from "components/settings/ChangePasswordModal.svelte" import ChangePasswordModal from "components/settings/ChangePasswordModal.svelte"
import UpdateAPIKeyModal from "components/settings/UpdateAPIKeyModal.svelte" import UpdateAPIKeyModal from "components/settings/UpdateAPIKeyModal.svelte"
import Logo from "assets/bb-emblem.svg" import Logo from "assets/bb-emblem.svg"
import { isEnabled, FEATURE_FLAGS } from "../../../helpers/featureFlags" import { isEnabled, TENANT_FEATURE_FLAGS } from "helpers/featureFlags"
let loaded = false let loaded = false
let userInfoModal let userInfoModal
@ -44,7 +44,7 @@
href: "/builder/portal/manage/users", href: "/builder/portal/manage/users",
heading: "Manage", heading: "Manage",
}, },
isEnabled(FEATURE_FLAGS.USER_GROUPS) isEnabled(TENANT_FEATURE_FLAGS.USER_GROUPS)
? { ? {
title: "User Groups", title: "User Groups",
href: "/builder/portal/manage/groups", href: "/builder/portal/manage/groups",
@ -103,7 +103,7 @@
]) ])
} }
if (isEnabled(FEATURE_FLAGS.LICENSING)) { if (isEnabled(TENANT_FEATURE_FLAGS.LICENSING)) {
// always show usage in self-host or cloud if licensing enabled // always show usage in self-host or cloud if licensing enabled
menu = menu.concat([ menu = menu.concat([
{ {

View file

@ -1,7 +1,7 @@
<script> <script>
import { PickerDropdown, notifications } from "@budibase/bbui" import { PickerDropdown } from "@budibase/bbui"
import { groups } from "stores/portal" import { groups } from "stores/portal"
import { onMount, createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -25,14 +25,6 @@
const appIds = groupSelected?.apps || null const appIds = groupSelected?.apps || null
dispatch("change", appIds) dispatch("change", appIds)
} }
onMount(async () => {
try {
await groups.actions.init()
} catch (error) {
notifications.error("Error")
}
})
</script> </script>
<PickerDropdown <PickerDropdown

View file

@ -31,8 +31,8 @@
onMount(async () => { onMount(async () => {
try { try {
await templates.load() await templates.load()
await licensing.getQuotaUsage() // always load latest
await licensing.getUsageMetrics() await licensing.init()
if ($templates?.length === 0) { if ($templates?.length === 0) {
notifications.error( notifications.error(
"There was a problem loading quick start templates." "There was a problem loading quick start templates."
@ -45,7 +45,7 @@
}) })
const initiateAppCreation = () => { const initiateAppCreation = () => {
if ($licensing.usageMetrics.apps >= 100) { if ($licensing?.usageMetrics?.apps >= 100) {
appLimitModal.show() appLimitModal.show()
} else { } else {
template = null template = null
@ -60,7 +60,7 @@
} }
const initiateAppImport = () => { const initiateAppImport = () => {
if ($licensing.usageMetrics.apps >= 100) { if ($licensing?.usageMetrics?.apps >= 100) {
appLimitModal.show() appLimitModal.show()
} else { } else {
template = { fromFile: true } template = { fromFile: true }
@ -117,7 +117,7 @@
</div> </div>
</div> </div>
<Divider size="S" /> <Divider />
{#if loaded && $templates?.length} {#if loaded && $templates?.length}
<TemplateDisplay templates={$templates} /> <TemplateDisplay templates={$templates} />

View file

@ -127,7 +127,7 @@
} }
const initiateAppCreation = async () => { const initiateAppCreation = async () => {
if ($licensing.usageMetrics.apps >= 100) { if ($licensing?.usageMetrics?.apps >= 100) {
appLimitModal.show() appLimitModal.show()
} else if ($apps?.length) { } else if ($apps?.length) {
$goto("/builder/portal/apps/create") $goto("/builder/portal/apps/create")
@ -229,9 +229,8 @@
try { try {
await apps.load() await apps.load()
await templates.load() await templates.load()
// always load latest
await licensing.getQuotaUsage() await licensing.init()
await licensing.getUsageMetrics()
if ($templates?.length === 0) { if ($templates?.length === 0) {
notifications.error( notifications.error(
@ -361,7 +360,7 @@
</Button> </Button>
{/if} {/if}
<div class="filter"> <div class="filter">
{#if $auth.groupsEnabled} {#if $licensing.groupsEnabled}
<AccessFilter on:change={accessFilterAction} /> <AccessFilter on:change={accessFilterAction} />
{/if} {/if}
<Select <Select

View file

@ -10,9 +10,7 @@
} }
} }
$: wide = $: wide = $page.path.includes("email/:template")
$page.path.includes("email/:template") ||
($page.path.includes("groups") && !$page.path.includes(":groupId"))
</script> </script>
{#if $auth.isAdmin} {#if $auth.isAdmin}

View file

@ -311,7 +311,7 @@
</Body> </Body>
</Layout> </Layout>
{#if providers.google} {#if providers.google}
<Divider size="S" /> <Divider />
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
<Heading size="S"> <Heading size="S">
<div class="provider-title"> <div class="provider-title">
@ -350,7 +350,7 @@
</Layout> </Layout>
{/if} {/if}
{#if providers.oidc} {#if providers.oidc}
<Divider size="S" /> <Divider />
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
<Heading size="S"> <Heading size="S">
<div class="provider-title"> <div class="provider-title">

View file

@ -132,7 +132,7 @@
values below and click activate. values below and click activate.
</Body> </Body>
</Layout> </Layout>
<Divider size="S" /> <Divider />
{#if smtpConfig} {#if smtpConfig}
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
<Heading size="S">SMTP</Heading> <Heading size="S">SMTP</Heading>
@ -186,7 +186,7 @@
Reset Reset
</Button> </Button>
</div> </div>
<Divider size="S" /> <Divider />
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
<Heading size="S">Templates</Heading> <Heading size="S">Templates</Heading>
<Body size="S"> <Body size="S">

View file

@ -5,13 +5,16 @@
Button, Button,
Layout, Layout,
Heading, Heading,
Body,
Icon, Icon,
Popover, Popover,
notifications, notifications,
List, List,
ListItem, ListItem,
StatusLight, StatusLight,
Divider,
ActionMenu,
MenuItem,
Modal,
} from "@budibase/bbui" } from "@budibase/bbui"
import UserGroupPicker from "components/settings/UserGroupPicker.svelte" import UserGroupPicker from "components/settings/UserGroupPicker.svelte"
import { createPaginationStore } from "helpers/pagination" import { createPaginationStore } from "helpers/pagination"
@ -19,91 +22,32 @@
import { onMount } from "svelte" import { onMount } from "svelte"
import { RoleUtils } from "@budibase/frontend-core" import { RoleUtils } from "@budibase/frontend-core"
import { roles } from "stores/backend" import { roles } from "stores/backend"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import CreateEditGroupModal from "./_components/CreateEditGroupModal.svelte"
import GroupIcon from "./_components/GroupIcon.svelte"
export let groupId export let groupId
let popoverAnchor let popoverAnchor
let popover let popover
let searchTerm = "" let searchTerm = ""
let selectedUsers = []
let prevSearch = undefined let prevSearch = undefined
let pageInfo = createPaginationStore() let pageInfo = createPaginationStore()
let loaded = false let loaded = false
let editModal
let deleteModal
$: page = $pageInfo.page $: page = $pageInfo.page
$: fetchUsers(page, searchTerm) $: fetchUsers(page, searchTerm)
$: group = $groups.find(x => x._id === groupId) $: group = $groups.find(x => x._id === groupId)
$: filtered = $users.data
async function addAll() { $: groupApps = $apps.filter(app =>
selectedUsers = [...selectedUsers, ...filtered.map(u => u._id)] groups.actions.getGroupAppIds(group).includes(apps.getProdAppID(app.appId))
)
let reducedUserObjects = filtered.map(u => { $: {
return { if (loaded && !group?._id) {
_id: u._id, $goto("./")
email: u.email,
}
})
group.users = [...reducedUserObjects, ...group.users]
await groups.actions.save(group)
$users.data.forEach(async user => {
let userToEdit = await users.get(user._id)
let userGroups = userToEdit.userGroups || []
userGroups.push(groupId)
await users.save({
...userToEdit,
userGroups,
})
})
}
async function selectUser(id) {
let selectedUser = selectedUsers.includes(id)
if (selectedUser) {
selectedUsers = selectedUsers.filter(id => id !== selectedUser)
let newUsers = group.users.filter(user => user._id !== id)
group.users = newUsers
} else {
let enrichedUser = $users.data
.filter(user => user._id === id)
.map(u => {
return {
_id: u._id,
email: u.email,
}
})[0]
selectedUsers = [...selectedUsers, id]
group.users.push(enrichedUser)
} }
await groups.actions.save(group)
let user = await users.get(id)
let userGroups = user.userGroups || []
userGroups.push(groupId)
await users.save({
...user,
userGroups,
})
}
$: filtered =
$users.data?.filter(x => !group?.users.map(y => y._id).includes(x._id)) ||
[]
$: groupApps = $apps.filter(x => group.apps.includes(x.appId))
async function removeUser(id) {
let newUsers = group.users.filter(user => user._id !== id)
group.users = newUsers
let user = await users.get(id)
await users.save({
...user,
userGroups: [],
})
await groups.actions.save(group)
} }
async function fetchUsers(page, search) { async function fetchUsers(page, search) {
@ -126,11 +70,29 @@
} }
const getRoleLabel = appId => { const getRoleLabel = appId => {
const roleId = group?.roles?.[`app_${appId}`] const roleId = group?.roles?.[apps.getProdAppID(appId)]
const role = $roles.find(x => x._id === roleId) const role = $roles.find(x => x._id === roleId)
return role?.name || "Custom role" return role?.name || "Custom role"
} }
async function deleteGroup() {
try {
await groups.actions.delete(group)
notifications.success("User group deleted successfully")
$goto("./")
} catch (error) {
notifications.error(`Failed to delete user group`)
}
}
async function saveGroup(group) {
try {
await groups.actions.save(group)
} catch (error) {
notifications.error(`Failed to save user group`)
}
}
onMount(async () => { onMount(async () => {
try { try {
await Promise.all([groups.actions.init(), apps.load(), roles.fetch()]) await Promise.all([groups.actions.init(), apps.load(), roles.fetch()])
@ -142,119 +104,137 @@
</script> </script>
{#if loaded} {#if loaded}
<Layout noPadding> <Layout noPadding gap="XL">
<div> <div>
<ActionButton <ActionButton on:click={() => $goto("../groups")} icon="ArrowLeft">
on:click={() => $goto("../groups")}
size="S"
icon="ArrowLeft"
>
Back Back
</ActionButton> </ActionButton>
</div> </div>
<div class="header">
<div class="title"> <Layout noPadding gap="M">
<div style="background: {group?.color};" class="circle"> <div class="header">
<div> <div class="title">
<Icon size="M" name={group?.icon} /> <GroupIcon {group} size="L" />
<div class="text-padding">
<Heading>{group?.name}</Heading>
</div> </div>
</div> </div>
<div class="text-padding"> <div>
<Heading>{group?.name}</Heading> <ActionMenu align="right">
<span slot="control">
<Icon hoverable name="More" />
</span>
<MenuItem icon="Refresh" on:click={() => editModal.show()}>
Edit
</MenuItem>
<MenuItem icon="Delete" on:click={() => deleteModal.show()}>
Delete
</MenuItem>
</ActionMenu>
</div> </div>
</div> </div>
<div bind:this={popoverAnchor}>
<Button on:click={popover.show()} icon="UserAdd" cta>Add user</Button>
</div>
<Popover align="right" bind:this={popover} anchor={popoverAnchor}>
<UserGroupPicker
key={"email"}
title={"User"}
bind:searchTerm
bind:selected={selectedUsers}
bind:filtered
{addAll}
select={selectUser}
/>
</Popover>
</div>
<List> <Divider />
{#if group?.users.length}
{#each group.users as user}
<ListItem title={user?.email} avatar
><Icon
on:click={() => removeUser(user?._id)}
hoverable
size="L"
name="Close"
/></ListItem
>
{/each}
{:else}
<ListItem icon="UserGroup" title="You have no users in this team" />
{/if}
</List>
<div
style="flex-direction: column; margin-top: var(--spacing-m)"
class="title"
>
<Heading weight="light" size="XS">Apps</Heading>
<div style="margin-top: var(--spacing-xs)">
<Body size="S"
>Manage apps that this User group has been assigned to</Body
>
</div>
</div>
<List> <Layout noPadding gap="S">
{#if groupApps.length} <div class="header">
{#each groupApps as app} <Heading size="S">Users</Heading>
<ListItem <div bind:this={popoverAnchor}>
title={app.name} <Button on:click={popover.show()} icon="UserAdd" cta>
icon={app?.icon?.name || "Apps"} Add user
iconBackground={app?.icon?.color || ""} </Button>
> </div>
<div class="title "> <Popover align="right" bind:this={popover} anchor={popoverAnchor}>
<StatusLight <UserGroupPicker
square bind:searchTerm
color={RoleUtils.getRoleColour(group.roles[`app_${app.appId}`])} labelKey="email"
selected={group.users?.map(user => user._id)}
list={$users.data}
on:select={e => groups.actions.addUser(groupId, e.detail)}
on:deselect={e => groups.actions.removeUser(groupId, e.detail)}
/>
</Popover>
</div>
<List>
{#if group?.users.length}
{#each group.users as user}
<ListItem
title={user.email}
on:click={() => $goto(`../users/${user._id}`)}
hoverable
> >
{getRoleLabel(app.appId)} <Icon
</StatusLight> on:click={e => {
</div> groups.actions.removeUser(groupId, user._id)
</ListItem> e.stopPropagation()
{/each} }}
{:else} hoverable
<ListItem icon="UserGroup" title="No apps" /> size="S"
{/if} name="Close"
</List> />
</ListItem>
{/each}
{:else}
<ListItem icon="UserGroup" title="This user group has no users" />
{/if}
</List>
</Layout>
</Layout>
<Layout noPadding gap="S">
<Heading size="S">Apps</Heading>
<List>
{#if groupApps.length}
{#each groupApps as app}
<ListItem
title={app.name}
icon={app?.icon?.name || "Apps"}
iconColor={app?.icon?.color || ""}
on:click={() => $goto(`../../overview/${app.devId}`)}
hoverable
>
<div class="title ">
<StatusLight
square
color={RoleUtils.getRoleColour(
group.roles[apps.getProdAppID(app.appId)]
)}
>
{getRoleLabel(app.appId)}
</StatusLight>
</div>
</ListItem>
{/each}
{:else}
<ListItem icon="Apps" title="This user group has access to no apps" />
{/if}
</List>
</Layout>
</Layout> </Layout>
{/if} {/if}
<style> <Modal bind:this={editModal}>
.text-padding { <CreateEditGroupModal {group} {saveGroup} />
margin-left: var(--spacing-l); </Modal>
} <ConfirmDialog
bind:this={deleteModal}
title="Delete user group"
okText="Delete user group"
onOk={deleteGroup}
>
Are you sure you wish to delete <b>{group?.name}?</b>
</ConfirmDialog>
<style>
.header { .header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-end;
} }
.title { .title {
display: flex; display: flex;
} justify-content: flex-start;
.circle { align-items: center;
border-radius: 50%; gap: var(--spacing-m);
height: 30px;
color: white;
font-weight: bold;
display: inline-block;
font-size: 1.2em;
width: 30px;
}
.circle > div {
padding: calc(1.5 * var(--spacing-xs)) var(--spacing-xs);
} }
</style> </style>

View file

@ -0,0 +1,24 @@
<script>
import { Icon } from "@budibase/bbui"
export let value
$: count = Object.keys(value || {}).length
</script>
<div class="align">
<div class="spacing">
<Icon name="WebPage" />
</div>
{count}
</div>
<style>
.align {
display: flex;
overflow: hidden;
}
.spacing {
margin-right: var(--spacing-m);
}
</style>

View file

@ -0,0 +1,8 @@
<script>
import { IconAvatar } from "@budibase/bbui"
export let group
export let size = "M"
</script>
<IconAvatar icon={group?.icon} background={group?.color} {size} />

View file

@ -1,20 +1,13 @@
<script> <script>
import { Avatar } from "@budibase/bbui" import GroupIcon from "./GroupIcon.svelte"
export let value export let value
export let row
</script> </script>
<div class="align"> <div class="align">
{#if value} {#if value}
<div class="spacing"> <GroupIcon group={row} />
<Avatar
size="L"
initials={value
.split(" ")
.map(x => x[0])
.join("")}
/>
</div>
{value} {value}
{:else} {:else}
<div class="text">-</div> <div class="text">-</div>
@ -26,12 +19,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
overflow: hidden; overflow: hidden;
gap: var(--spacing-m);
} }
.spacing {
margin-right: var(--spacing-m);
}
.text { .text {
opacity: 0.8; opacity: 0.8;
} }

View file

@ -1,129 +0,0 @@
<script>
import {
Button,
Icon,
Body,
ActionMenu,
MenuItem,
Modal,
} from "@budibase/bbui"
import { goto } from "@roxi/routify"
import CreateEditGroupModal from "./CreateEditGroupModal.svelte"
export let group
export let deleteGroup
export let saveGroup
let modal
function editGroup() {
modal.show()
}
</script>
<div class="title">
<div class="name" style="display: flex; margin-left: var(--spacing-xl)">
<div style="background: {group.color};" class="circle">
<div>
<Icon size="M" name={group.icon} />
</div>
</div>
<div class="name" data-cy="app-name-link">
<Body size="S">{group.name}</Body>
</div>
</div>
</div>
<div class="desktop tableElement">
<Icon name="User" />
<div style="margin-left: var(--spacing-l">
{parseInt(group?.users?.length) || 0} user{parseInt(
group?.users?.length
) === 1
? ""
: "s"}
</div>
</div>
<div class="desktop tableElement">
<Icon name="WebPage" />
<div style="margin-left: var(--spacing-l)">
{parseInt(group?.apps?.length) || 0} app{parseInt(group?.apps?.length) === 1
? ""
: "s"}
</div>
</div>
<div>
<div class="group-row-actions">
<div>
<Button on:click={() => $goto(`./${group._id}`)} size="S" cta
>Manage</Button
>
</div>
<div>
<ActionMenu align="right">
<span slot="control">
<Icon hoverable name="More" />
</span>
<MenuItem on:click={() => deleteGroup(group)} icon="Delete"
>Delete</MenuItem
>
<MenuItem on:click={() => editGroup(group)} icon="Edit">Edit</MenuItem>
</ActionMenu>
</div>
</div>
</div>
<Modal bind:this={modal}>
<CreateEditGroupModal {group} {saveGroup} />
</Modal>
<style>
.group-row-actions {
display: flex;
float: right;
margin-right: var(--spacing-xl);
grid-template-columns: 75px 75px;
grid-gap: var(--spacing-xl);
}
.name {
grid-gap: var(--spacing-xl);
grid-template-columns: 75px 75px;
align-items: center;
}
.circle {
border-radius: 50%;
height: 30px;
color: white;
font-weight: bold;
display: inline-block;
font-size: 1.2em;
width: 30px;
}
.tableElement {
display: flex;
}
.circle > div {
padding: calc(1.5 * var(--spacing-xs)) var(--spacing-xs);
}
.name {
text-decoration: none;
overflow: hidden;
}
.name :global(.spectrum-Heading) {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin-left: calc(1.5 * var(--spacing-xl));
}
.title :global(h1:hover) {
color: var(--spectrum-global-color-blue-600);
cursor: pointer;
transition: color 130ms ease;
}
@media (max-width: 640px) {
.desktop {
display: none !important;
}
}
</style>

View file

@ -0,0 +1,22 @@
<script>
import { Icon } from "@budibase/bbui"
export let value
</script>
<div class="align">
<div class="spacing">
<Icon name="User" />
</div>
{parseInt(value?.length) || 0}
</div>
<style>
.align {
display: flex;
overflow: hidden;
}
.spacing {
margin-right: var(--spacing-m);
}
</style>

View file

@ -4,16 +4,23 @@
Heading, Heading,
Body, Body,
Button, Button,
ButtonGroup,
Modal, Modal,
Tag, Tag,
Tags, Tags,
Table,
Divider,
Search,
notifications, notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import { groups, auth } from "stores/portal" import { groups, auth, licensing, admin } from "stores/portal"
import { onMount } from "svelte" import { onMount } from "svelte"
import CreateEditGroupModal from "./_components/CreateEditGroupModal.svelte" import CreateEditGroupModal from "./_components/CreateEditGroupModal.svelte"
import UserGroupsRow from "./_components/UserGroupsRow.svelte"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import GroupAppsTableRenderer from "./_components/GroupAppsTableRenderer.svelte"
import UsersTableRenderer from "./_components/UsersTableRenderer.svelte"
import GroupNameTableRenderer from "./_components/GroupNameTableRenderer.svelte"
import { goto } from "@roxi/routify"
const DefaultGroup = { const DefaultGroup = {
name: "", name: "",
@ -23,20 +30,38 @@
apps: [], apps: [],
roles: {}, roles: {},
} }
let modal
let group = cloneDeep(DefaultGroup)
async function deleteGroup(group) { let modal
try { let searchString
groups.actions.delete(group) let group = cloneDeep(DefaultGroup)
} catch (error) { let customRenderers = [
notifications.error(`Failed to delete group`) { column: "name", component: GroupNameTableRenderer },
{ column: "users", component: UsersTableRenderer },
{ column: "roles", component: GroupAppsTableRenderer },
]
$: schema = {
name: {},
users: { sortable: false },
roles: { sortable: false, displayName: "Apps" },
}
$: filteredGroups = filterGroups($groups, searchString)
const filterGroups = (groups, searchString) => {
if (!searchString) {
return groups
} }
searchString = searchString.toLocaleLowerCase()
return groups?.filter(group => {
return group.name?.toLowerCase().includes(searchString)
})
} }
async function saveGroup(group) { async function saveGroup(group) {
try { try {
await groups.actions.save(group) group = await groups.actions.save(group)
$goto(`./${group._id}`)
notifications.success(`User group created successfully`)
} catch (error) { } catch (error) {
if (error.status === 400) { if (error.status === 400) {
notifications.error(error.message) notifications.error(error.message)
@ -53,62 +78,81 @@
onMount(async () => { onMount(async () => {
try { try {
if ($auth.groupsEnabled) { // always load latest
await licensing.init()
if ($licensing.groupsEnabled) {
await groups.actions.init() await groups.actions.init()
} }
} catch (error) { } catch (error) {
notifications.error("Error getting User groups") notifications.error("Error getting user groups")
} }
}) })
</script> </script>
<Layout noPadding> <Layout noPadding gap="M">
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
<div style="display: flex;"> <Heading size="M">User groups</Heading>
<Heading size="M">User groups</Heading> {#if !$licensing.groupsEnabled}
{#if !$auth.groupsEnabled} <Tags>
<Tags> <div class="tags">
<div class="tags"> <div class="tag">
<div class="tag"> <Tag icon="LockClosed">Pro plan</Tag>
<Tag icon="LockClosed">Pro plan</Tag>
</div>
</div> </div>
</Tags>
{/if}
</div>
<Body>Easily assign and manage your users access with User Groups</Body>
</Layout>
<div class="align-buttons">
<Button
newStyles
icon={$auth.groupsEnabled ? "UserGroup" : ""}
cta={$auth.groupsEnabled}
on:click={$auth.groupsEnabled
? showCreateGroupModal
: window.open("https://budibase.com/pricing/", "_blank")}
>
{$auth.groupsEnabled ? "Create user group" : "Upgrade Account"}
</Button>
{#if !$auth.groupsEnabled}
<Button
newStyles
secondary
on:click={() => {
window.open("https://budibase.com/pricing/", "_blank")
}}>View Plans</Button
>
{/if}
</div>
{#if $auth.groupsEnabled && $groups.length}
<div class="groupTable">
{#each $groups as group}
<div>
<UserGroupsRow {saveGroup} {deleteGroup} {group} />
</div> </div>
{/each} </Tags>
{/if}
<Body>
Easily assign and manage your users' access with user groups.
{#if !$auth.accountPortalAccess && !$licensing.groupsEnabled && $admin.cloud}
Contact your account holder to upgrade your plan.
{/if}
</Body>
</Layout>
<Divider />
<div class="controls">
<ButtonGroup>
{#if $licensing.groupsEnabled}
<!--Show the group create button-->
<Button
newStyles
icon={"UserGroup"}
cta
on:click={showCreateGroupModal}
>
Create user group
</Button>
{:else}
<Button
newStyles
disabled={!$auth.accountPortalAccess && $admin.cloud}
on:click={$licensing.goToUpgradePage()}
>
Upgrade
</Button>
<!--Show the view plans button-->
<Button
newStyles
secondary
on:click={() => {
window.open("https://budibase.com/pricing/", "_blank")
}}
>
View Plans
</Button>
{/if}
</ButtonGroup>
<div class="controls-right">
<Search bind:value={searchString} placeholder="Search" />
</div> </div>
{/if} </div>
<Table
on:click={({ detail }) => $goto(`./${detail._id}`)}
{schema}
data={filteredGroups}
allowEditColumns={false}
allowEditRows={false}
{customRenderers}
/>
</Layout> </Layout>
<Modal bind:this={modal}> <Modal bind:this={modal}>
@ -116,37 +160,24 @@
</Modal> </Modal>
<style> <style>
.align-buttons { .controls {
display: flex; display: flex;
column-gap: var(--spacing-xl); flex-direction: row;
justify-content: space-between;
align-items: center;
}
.controls-right {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
gap: var(--spacing-xl);
}
.controls-right :global(.spectrum-Search) {
width: 200px;
} }
.tag { .tag {
margin-top: var(--spacing-xs); margin-top: var(--spacing-xs);
margin-left: var(--spacing-m); margin-left: var(--spacing-m);
} }
.groupTable {
display: grid;
grid-template-rows: auto;
align-items: center;
border-bottom: 1px solid var(--spectrum-alias-border-color-mid);
border-left: 1px solid var(--spectrum-alias-border-color-mid);
background: var(--spectrum-global-color-gray-50);
}
.groupTable :global(> div) {
background: var(--bg-color);
height: 55px;
display: grid;
align-items: center;
grid-gap: var(--spacing-xl);
grid-template-columns: 2fr 2fr 2fr auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0 var(--spacing-s);
border-top: 1px solid var(--spectrum-alias-border-color-mid);
border-right: 1px solid var(--spectrum-alias-border-color-mid);
}
</style> </style>

View file

@ -45,7 +45,7 @@
<Layout noPadding> <Layout noPadding>
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
<Heading size="M">Plugins</Heading> <Heading size="M">Plugins</Heading>
<Body>Add your own custom datasources and components</Body> <Body>Add your own custom datasources and components.</Body>
</Layout> </Layout>
<Divider size="S" /> <Divider size="S" />
<Layout noPadding> <Layout noPadding>

View file

@ -19,17 +19,17 @@
Modal, Modal,
notifications, notifications,
Divider, Divider,
Banner,
StatusLight, StatusLight,
} from "@budibase/bbui" } from "@budibase/bbui"
import { onMount } from "svelte" import { onMount } from "svelte"
import { fetchData } from "helpers" import { users, auth, groups, apps, licensing } from "stores/portal"
import { users, auth, groups, apps } from "stores/portal"
import { roles } from "stores/backend" import { roles } from "stores/backend"
import { Constants } from "@budibase/frontend-core"
import ForceResetPasswordModal from "./_components/ForceResetPasswordModal.svelte" import ForceResetPasswordModal from "./_components/ForceResetPasswordModal.svelte"
import { RoleUtils } from "@budibase/frontend-core"
import UserGroupPicker from "components/settings/UserGroupPicker.svelte" import UserGroupPicker from "components/settings/UserGroupPicker.svelte"
import DeleteUserModal from "./_components/DeleteUserModal.svelte" import DeleteUserModal from "./_components/DeleteUserModal.svelte"
import GroupIcon from "../groups/_components/GroupIcon.svelte"
import { Constants, RoleUtils } from "@budibase/frontend-core"
export let userId export let userId
@ -38,59 +38,57 @@
let popoverAnchor let popoverAnchor
let searchTerm = "" let searchTerm = ""
let popover let popover
let selectedGroups = []
let allAppList = []
let user let user
let loaded = false let loaded = false
$: fetchUser(userId) $: fullName = user?.firstName ? user?.firstName + " " + user?.lastName : ""
$: fullName = $userFetch?.data?.firstName $: privileged = user?.admin?.global || user?.builder?.global
? $userFetch?.data?.firstName + " " + $userFetch?.data?.lastName $: nameLabel = getNameLabel(user)
: ""
$: nameLabel = getNameLabel($userFetch)
$: initials = getInitials(nameLabel) $: initials = getInitials(nameLabel)
$: allAppList = $apps $: filteredGroups = getFilteredGroups($groups, searchTerm)
.filter(x => { $: availableApps = getAvailableApps($apps, privileged, user?.roles)
if ($userFetch.data?.roles) {
return Object.keys($userFetch.data.roles).find(y => {
return x.appId === apps.extractAppId(y)
})
}
})
.map(app => {
let roles = Object.fromEntries(
Object.entries($userFetch.data.roles).filter(([key]) => {
return apps.extractAppId(key) === app.appId
})
)
return {
name: app.name,
devId: app.devId,
icon: app.icon,
roles,
}
})
// Used for searching through groups in the add group popover
$: filteredGroups = $groups.filter(
group =>
selectedGroups &&
group?.name?.toLowerCase().includes(searchTerm.toLowerCase())
)
$: userGroups = $groups.filter(x => { $: userGroups = $groups.filter(x => {
return x.users?.find(y => { return x.users?.find(y => {
return y._id === userId return y._id === userId
}) })
}) })
$: globalRole = $userFetch?.data?.admin?.global $: globalRole = user?.admin?.global
? "admin" ? "admin"
: $userFetch?.data?.builder?.global : user?.builder?.global
? "developer" ? "developer"
: "appUser" : "appUser"
const userFetch = fetchData(`/api/global/users/${userId}`) const getAvailableApps = (appList, privileged, roles) => {
let availableApps = appList.slice()
if (!privileged) {
availableApps = availableApps.filter(x => {
return Object.keys(roles || {}).find(y => {
return x.appId === apps.extractAppId(y)
})
})
}
return availableApps.map(app => {
const prodAppId = apps.getProdAppID(app.appId)
console.log(prodAppId)
return {
name: app.name,
devId: app.devId,
icon: app.icon,
role: privileged ? Constants.Roles.ADMIN : roles[prodAppId],
}
})
}
const getNameLabel = userFetch => { const getFilteredGroups = (groups, search) => {
const { firstName, lastName, email } = userFetch?.data || {} if (!search) {
return groups
}
search = search.toLowerCase()
return groups.filter(group => group.name?.toLowerCase().includes(search))
}
const getNameLabel = user => {
const { firstName, lastName, email } = user || {}
if (!firstName && !lastName) { if (!firstName && !lastName) {
return email || "" return email || ""
} }
@ -122,38 +120,19 @@
return role?.name || "Custom role" return role?.name || "Custom role"
} }
function getHighestRole(roles) {
let highestRole
let highestRoleNumber = 0
Object.keys(roles).forEach(role => {
let roleNumber = RoleUtils.getRolePriority(roles[role])
if (roleNumber > highestRoleNumber) {
highestRoleNumber = roleNumber
highestRole = roles[role]
}
})
return highestRole
}
async function updateUserFirstName(evt) { async function updateUserFirstName(evt) {
try { try {
await users.save({ ...$userFetch?.data, firstName: evt.target.value }) await users.save({ ...user, firstName: evt.target.value })
await userFetch.refresh() await fetchUser()
} catch (error) { } catch (error) {
notifications.error("Error updating user") notifications.error("Error updating user")
} }
} }
async function removeGroup(id) {
let updatedGroup = $groups.find(x => x._id === id)
let newUsers = updatedGroup.users.filter(user => user._id !== userId)
updatedGroup.users = newUsers
groups.actions.save(updatedGroup)
}
async function updateUserLastName(evt) { async function updateUserLastName(evt) {
try { try {
await users.save({ ...$userFetch?.data, lastName: evt.target.value }) await users.save({ ...user, lastName: evt.target.value })
await userFetch.refresh() await fetchUser()
} catch (error) { } catch (error) {
notifications.error("Error updating user") notifications.error("Error updating user")
} }
@ -169,40 +148,40 @@
} }
} }
async function addGroup(groupId) { async function fetchUser() {
let selectedGroup = selectedGroups.includes(groupId) user = await users.get(userId)
let group = $groups.find(group => group._id === groupId) if (!user?._id) {
$goto("./")
if (selectedGroup) {
selectedGroups = selectedGroups.filter(id => id === selectedGroup)
let newUsers = group.users.filter(groupUser => user._id !== groupUser._id)
group.users = newUsers
} else {
selectedGroups = [...selectedGroups, groupId]
group.users.push(user)
} }
await groups.actions.save(group)
}
async function fetchUser(userId) {
let userPromise = users.get(userId)
user = await userPromise
} }
async function toggleFlags(detail) { async function toggleFlags(detail) {
try { try {
await users.save({ ...$userFetch?.data, ...detail }) await users.save({ ...user, ...detail })
await userFetch.refresh() await fetchUser()
} catch (error) { } catch (error) {
notifications.error("Error updating user") notifications.error("Error updating user")
} }
} }
function addAll() {} const addGroup = async groupId => {
await groups.actions.addUser(groupId, userId)
await fetchUser()
}
const removeGroup = async groupId => {
await groups.actions.removeUser(groupId, userId)
await fetchUser()
}
onMount(async () => { onMount(async () => {
try { try {
await Promise.all([groups.actions.init(), apps.load(), roles.fetch()]) await Promise.all([
fetchUser(),
groups.actions.init(),
apps.load(),
roles.fetch(),
])
loaded = true loaded = true
} catch (error) { } catch (error) {
notifications.error("Error getting user groups") notifications.error("Error getting user groups")
@ -225,13 +204,13 @@
<Avatar size="XXL" {initials} /> <Avatar size="XXL" {initials} />
<div class="subtitle"> <div class="subtitle">
<Heading size="S">{nameLabel}</Heading> <Heading size="S">{nameLabel}</Heading>
{#if nameLabel !== $userFetch?.data?.email} {#if nameLabel !== user?.email}
<Body size="S">{$userFetch?.data?.email}</Body> <Body size="S">{user?.email}</Body>
{/if} {/if}
</div> </div>
</div> </div>
</div> </div>
{#if userId !== $auth.user._id} {#if userId !== $auth.user?._id}
<div> <div>
<ActionMenu align="right"> <ActionMenu align="right">
<span slot="control"> <span slot="control">
@ -247,27 +226,21 @@
</div> </div>
{/if} {/if}
</div> </div>
<Divider size="S" /> <Divider />
<Layout noPadding gap="S"> <Layout noPadding gap="S">
<Heading size="S">Details</Heading> <Heading size="S">Details</Heading>
<div class="fields"> <div class="fields">
<div class="field"> <div class="field">
<Label size="L">Email</Label> <Label size="L">Email</Label>
<Input disabled value={$userFetch?.data?.email} /> <Input disabled value={user?.email} />
</div> </div>
<div class="field"> <div class="field">
<Label size="L">First name</Label> <Label size="L">First name</Label>
<Input <Input value={user?.firstName} on:blur={updateUserFirstName} />
value={$userFetch?.data?.firstName}
on:blur={updateUserFirstName}
/>
</div> </div>
<div class="field"> <div class="field">
<Label size="L">Last name</Label> <Label size="L">Last name</Label>
<Input <Input value={user?.lastName} on:blur={updateUserLastName} />
value={$userFetch?.data?.lastName}
on:blur={updateUserLastName}
/>
</div> </div>
<!-- don't let a user remove the privileges that let them be here --> <!-- don't let a user remove the privileges that let them be here -->
{#if userId !== $auth.user._id} {#if userId !== $auth.user._id}
@ -284,7 +257,7 @@
</Layout> </Layout>
</Layout> </Layout>
{#if $auth.groupsEnabled} {#if $licensing.groupsEnabled}
<!-- User groups --> <!-- User groups -->
<Layout gap="S" noPadding> <Layout gap="S" noPadding>
<div class="tableTitle"> <div class="tableTitle">
@ -301,13 +274,14 @@
</div> </div>
<Popover align="right" bind:this={popover} anchor={popoverAnchor}> <Popover align="right" bind:this={popover} anchor={popoverAnchor}>
<UserGroupPicker <UserGroupPicker
key={"name"} labelKey="name"
title={"User group"}
bind:searchTerm bind:searchTerm
bind:selected={selectedGroups} list={filteredGroups}
bind:filtered={filteredGroups} selected={user.userGroups}
{addAll} on:select={e => addGroup(e.detail)}
select={addGroup} on:deselect={e => removeGroup(e.detail)}
iconComponent={GroupIcon}
extractIconProps={item => ({ group: item, size: "S" })}
/> />
</Popover> </Popover>
</div> </div>
@ -322,7 +296,10 @@
on:click={() => $goto(`../groups/${group._id}`)} on:click={() => $goto(`../groups/${group._id}`)}
> >
<Icon <Icon
on:click={removeGroup(group._id)} on:click={e => {
removeGroup(group._id)
e.stopPropagation()
}}
hoverable hoverable
size="S" size="S"
name="Close" name="Close"
@ -330,7 +307,7 @@
</ListItem> </ListItem>
{/each} {/each}
{:else} {:else}
<ListItem icon="UserGroup" title="No groups" /> <ListItem icon="UserGroup" title="This user is in no user groups" />
{/if} {/if}
</List> </List>
</Layout> </Layout>
@ -339,27 +316,28 @@
<Layout gap="S" noPadding> <Layout gap="S" noPadding>
<Heading size="S">Apps</Heading> <Heading size="S">Apps</Heading>
<List> <List>
{#if allAppList.length} {#if privileged}
{#each allAppList as app} <Banner showCloseButton={false}>
This user's role grants admin access to all apps
</Banner>
{:else if availableApps.length}
{#each availableApps as app}
<ListItem <ListItem
title={app.name} title={app.name}
iconBackground={app?.icon?.color || ""} iconColor={app?.icon?.color}
icon={app?.icon?.name || "Apps"} icon={app?.icon?.name || "Apps"}
hoverable hoverable
on:click={() => $goto(`../../overview/${app.devId}`)} on:click={() => $goto(`../../overview/${app.devId}`)}
> >
<div class="title "> <div class="title ">
<StatusLight <StatusLight square color={RoleUtils.getRoleColour(app.role)}>
square {getRoleLabel(app.role)}
color={RoleUtils.getRoleColour(getHighestRole(app.roles))}
>
{getRoleLabel(getHighestRole(app.roles))}
</StatusLight> </StatusLight>
</div> </div>
</ListItem> </ListItem>
{/each} {/each}
{:else} {:else}
<ListItem icon="Apps" title="No apps" /> <ListItem icon="Apps" title="This user has access to no apps" />
{/if} {/if}
</List> </List>
</Layout> </Layout>
@ -367,13 +345,10 @@
{/if} {/if}
<Modal bind:this={deleteModal}> <Modal bind:this={deleteModal}>
<DeleteUserModal user={$userFetch.data} /> <DeleteUserModal {user} />
</Modal> </Modal>
<Modal bind:this={resetPasswordModal}> <Modal bind:this={resetPasswordModal}>
<ForceResetPasswordModal <ForceResetPasswordModal {user} on:update={fetchUser} />
user={$userFetch.data}
on:update={userFetch.refresh}
/>
</Modal> </Modal>
<style> <style>

View file

@ -8,7 +8,7 @@
Layout, Layout,
Icon, Icon,
} from "@budibase/bbui" } from "@budibase/bbui"
import { groups, auth } from "stores/portal" import { groups, licensing } from "stores/portal"
import { Constants } from "@budibase/frontend-core" import { Constants } from "@budibase/frontend-core"
import { emailValidator } from "helpers/validation" import { emailValidator } from "helpers/validation"
@ -117,7 +117,7 @@
</div> </div>
</Layout> </Layout>
{#if $auth.groupsEnabled} {#if $licensing.groupsEnabled}
<Multiselect <Multiselect
bind:value={userGroups} bind:value={userGroups}
placeholder="No groups" placeholder="No groups"

View file

@ -1,13 +1,19 @@
<script> <script>
import { Icon } from "@budibase/bbui" import { Icon } from "@budibase/bbui"
import { apps } from "stores/portal"
export let value export let value
export let row
$: priviliged = row?.admin?.global || row?.builder?.global
$: count = priviliged ? $apps.length : value?.length || 0
</script> </script>
<div class="align"> <div class="align">
<div class="spacing"> <div class="spacing">
<Icon name="WebPage" /> <Icon name="WebPage" />
</div> </div>
{parseInt(value?.length) || 0} {count}
</div> </div>
<style> <style>
@ -15,7 +21,6 @@
display: flex; display: flex;
overflow: hidden; overflow: hidden;
} }
.spacing { .spacing {
margin-right: var(--spacing-m); margin-right: var(--spacing-m);
} }

View file

@ -2,7 +2,6 @@
import { Body, ModalContent, Table } from "@budibase/bbui" import { Body, ModalContent, Table } from "@budibase/bbui"
import { onMount } from "svelte" import { onMount } from "svelte"
export let userData
export let deleteUsersResponse export let deleteUsersResponse
let successCount let successCount

View file

@ -18,11 +18,9 @@
display: flex; display: flex;
overflow: hidden; overflow: hidden;
} }
.opacity { .opacity {
opacity: 0.8; opacity: 0.8;
} }
.spacing { .spacing {
margin-right: var(--spacing-m); margin-right: var(--spacing-m);
} }

View file

@ -6,7 +6,7 @@
Multiselect, Multiselect,
notifications, notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import { groups, auth, admin } from "stores/portal" import { groups, licensing, admin } from "stores/portal"
import { emailValidator } from "helpers/validation" import { emailValidator } from "helpers/validation"
import { Constants } from "@budibase/frontend-core" import { Constants } from "@budibase/frontend-core"
@ -72,7 +72,6 @@
size="M" size="M"
title="Import users" title="Import users"
confirmText="Done" confirmText="Done"
showCancelButton={false}
cancelText="Cancel" cancelText="Cancel"
showCloseIcon={false} showCloseIcon={false}
onConfirm={() => createUsersFromCsv({ userEmails, usersRole, userGroups })} onConfirm={() => createUsersFromCsv({ userEmails, usersRole, userGroups })}
@ -92,7 +91,7 @@
options={Constants.BuilderRoleDescriptions} options={Constants.BuilderRoleDescriptions}
/> />
{#if $auth.groupsEnabled} {#if $licensing.groupsEnabled}
<Multiselect <Multiselect
bind:value={userGroups} bind:value={userGroups}
placeholder="No groups" placeholder="No groups"

View file

@ -7,13 +7,14 @@
Table, Table,
Layout, Layout,
Modal, Modal,
ModalContent,
Search, Search,
notifications, notifications,
Pagination, Pagination,
Divider, Divider,
} from "@budibase/bbui" } from "@budibase/bbui"
import AddUserModal from "./_components/AddUserModal.svelte" import AddUserModal from "./_components/AddUserModal.svelte"
import { users, groups, auth } from "stores/portal" import { users, groups, auth, licensing } from "stores/portal"
import { onMount } from "svelte" import { onMount } from "svelte"
import DeleteRowsButton from "components/backend/DataTable/buttons/DeleteRowsButton.svelte" import DeleteRowsButton from "components/backend/DataTable/buttons/DeleteRowsButton.svelte"
import GroupsTableRenderer from "./_components/GroupsTableRenderer.svelte" import GroupsTableRenderer from "./_components/GroupsTableRenderer.svelte"
@ -22,48 +23,52 @@
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import OnboardingTypeModal from "./_components/OnboardingTypeModal.svelte" import OnboardingTypeModal from "./_components/OnboardingTypeModal.svelte"
import PasswordModal from "./_components/PasswordModal.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 ImportUsersModal from "./_components/ImportUsersModal.svelte"
import { createPaginationStore } from "helpers/pagination"
import { get } from "svelte/store" import { get } from "svelte/store"
import { Constants } from "@budibase/frontend-core" import { Constants, Utils, fetchData } from "@budibase/frontend-core"
import { API } from "api"
const fetch = fetchData({
API,
datasource: {
type: "user",
},
})
let loaded = false
let enrichedUsers = [] let enrichedUsers = []
let createUserModal, let createUserModal,
inviteConfirmationModal, inviteConfirmationModal,
onboardingTypeModal, onboardingTypeModal,
passwordModal, passwordModal,
importUsersModal, importUsersModal
deletionFailureModal let searchEmail = undefined
let pageInfo = createPaginationStore()
let prevEmail = undefined,
searchEmail = undefined
let selectedRows = [] let selectedRows = []
let bulkSaveResponse
let customRenderers = [ let customRenderers = [
{ column: "userGroups", component: GroupsTableRenderer }, { column: "userGroups", component: GroupsTableRenderer },
{ column: "apps", component: AppsTableRenderer }, { column: "apps", component: AppsTableRenderer },
{ column: "role", component: RoleTableRenderer }, { column: "role", component: RoleTableRenderer },
] ]
let userData = []
$: debouncedUpdateFetch(searchEmail)
$: schema = { $: schema = {
email: {}, email: {
sortable: false,
},
role: { role: {
sortable: false, sortable: false,
}, },
...($auth.groupsEnabled && { ...($licensing.groupsEnabled && {
userGroups: { sortable: false, displayName: "Groups" }, userGroups: { sortable: false, displayName: "Groups" },
}), }),
apps: {}, apps: {
sortable: false,
},
} }
$: userData = []
$: createUsersResponse = { successful: [], unsuccessful: [] }
$: deleteUsersResponse = { successful: [], unsuccessful: [] }
$: inviteUsersResponse = { successful: [], unsuccessful: [] }
$: page = $pageInfo.page
$: fetchUsers(page, searchEmail)
$: { $: {
enrichedUsers = $users.data?.map(user => { enrichedUsers = $fetch.rows?.map(user => {
let userGroups = [] let userGroups = []
$groups.forEach(group => { $groups.forEach(group => {
if (group.users) { if (group.users) {
@ -83,6 +88,15 @@
}) })
} }
const updateFetch = email => {
fetch.update({
query: {
email,
},
})
}
const debouncedUpdateFetch = Utils.debounce(updateFetch, 250)
const showOnboardingTypeModal = async addUsersData => { const showOnboardingTypeModal = async addUsersData => {
userData = await removingDuplicities(addUsersData) userData = await removingDuplicities(addUsersData)
if (!userData?.users?.length) return if (!userData?.users?.length) return
@ -95,9 +109,11 @@
email: user.email, email: user.email,
builder: user.role === Constants.BudibaseRoles.Developer, builder: user.role === Constants.BudibaseRoles.Developer,
admin: user.role === Constants.BudibaseRoles.Admin, admin: user.role === Constants.BudibaseRoles.Admin,
groups: userData.groups,
})) }))
try { try {
inviteUsersResponse = await users.invite(payload) const res = await users.invite(payload)
notifications.success(res.message)
inviteConfirmationModal.show() inviteConfirmationModal.show()
} catch (error) { } catch (error) {
notifications.error("Error inviting user") notifications.error("Error inviting user")
@ -120,9 +136,8 @@
newUsers.push(user) newUsers.push(user)
} }
if (!newUsers.length) { if (!newUsers.length)
notifications.info("Duplicated! There is no new users to add.") notifications.info("Duplicated! There is no new users to add.")
}
return { ...userData, users: newUsers } return { ...userData, users: newUsers }
} }
@ -149,12 +164,11 @@
async function createUsers() { async function createUsers() {
try { try {
createUsersResponse = await users.create( bulkSaveResponse = await users.create(await removingDuplicities(userData))
await removingDuplicities(userData)
)
notifications.success("Successfully created user") notifications.success("Successfully created user")
await groups.actions.init() await groups.actions.init()
passwordModal.show() passwordModal.show()
await fetch.refresh()
} catch (error) { } catch (error) {
notifications.error("Error creating user") notifications.error("Error creating user")
} }
@ -162,20 +176,12 @@
async function chooseCreationType(onboardingType) { async function chooseCreationType(onboardingType) {
if (onboardingType === "emailOnboarding") { if (onboardingType === "emailOnboarding") {
createUserFlow() await createUserFlow()
} else { } else {
await createUsers() await createUsers()
} }
} }
onMount(async () => {
try {
await groups.actions.init()
} catch (error) {
notifications.error("Error fetching User Group data")
}
})
const deleteRows = async () => { const deleteRows = async () => {
try { try {
let ids = selectedRows.map(user => user._id) let ids = selectedRows.map(user => user._id)
@ -183,105 +189,100 @@
notifications.error("You cannot delete yourself") notifications.error("You cannot delete yourself")
return return
} }
deleteUsersResponse = await users.bulkDelete(ids) await users.bulkDelete(ids)
if (deleteUsersResponse.unsuccessful?.length) { notifications.success(`Successfully deleted ${selectedRows.length} rows`)
deletionFailureModal.show()
} else {
notifications.success(
`Successfully deleted ${selectedRows.length} users`
)
}
selectedRows = [] selectedRows = []
await fetchUsers(page, searchEmail) await fetch.refresh()
} catch (error) { } catch (error) {
notifications.error("Error deleting rows") notifications.error("Error deleting rows")
} }
} }
async function fetchUsers(page, email) { onMount(async () => {
if ($pageInfo.loading) {
return
}
// need to remove the page if they've started searching
if (email && !prevEmail) {
pageInfo.reset()
page = undefined
}
prevEmail = email
try { try {
pageInfo.loading() loaded = false
await users.search({ page, email }) await groups.actions.init()
pageInfo.fetched($users.hasNextPage, $users.nextPage) loaded = true
} catch (error) { } catch (error) {
notifications.error("Error getting user list") notifications.error("Error fetching User Group data")
} }
} })
</script> </script>
<Layout noPadding gap="M"> {#if loaded && $fetch.loaded}
<Layout gap="XS" noPadding> <Layout noPadding gap="M">
<Heading>Users</Heading> <Layout gap="XS" noPadding>
<Body>Add users and control who gets access to your published apps</Body> <Heading>Users</Heading>
</Layout> <Body>Add users and control who gets access to your published apps.</Body>
<Divider size="S" /> </Layout>
<div class="controls"> <Divider />
<ButtonGroup> <div class="controls">
<Button <ButtonGroup>
dataCy="add-user" <Button
on:click={createUserModal.show} dataCy="add-user"
icon="UserAdd" on:click={createUserModal.show}
cta>Add users</Button icon="UserAdd"
> cta
<Button >Add users
on:click={importUsersModal.show} </Button>
icon="Import" <Button
secondary on:click={importUsersModal.show}
newStyles icon="Import"
> secondary
Import users newStyles
</Button> >
</ButtonGroup> Import users
<div class="controls-right"> </Button>
<Search bind:value={searchEmail} placeholder="Search email" /> </ButtonGroup>
{#if selectedRows.length > 0} <div class="controls-right">
<DeleteRowsButton <Search bind:value={searchEmail} placeholder="Search" />
item="user" {#if selectedRows.length > 0}
on:updaterows <DeleteRowsButton
{selectedRows} item="user"
{deleteRows} on:updaterows
/> {selectedRows}
{/if} {deleteRows}
/>
{/if}
</div>
</div> </div>
</div> <Table
<Table on:click={({ detail }) => $goto(`./${detail._id}`)}
on:click={({ detail }) => $goto(`./${detail._id}`)} {schema}
{schema} bind:selectedRows
bind:selectedRows data={enrichedUsers}
data={enrichedUsers} allowEditColumns={false}
allowEditColumns={false} allowEditRows={false}
allowEditRows={false} allowSelectRows={true}
allowSelectRows={true} {customRenderers}
showHeaderBorder={false}
{customRenderers}
/>
<div class="pagination">
<Pagination
page={$pageInfo.pageNumber}
hasPrevPage={$pageInfo.loading ? false : $pageInfo.hasPrevPage}
hasNextPage={$pageInfo.loading ? false : $pageInfo.hasNextPage}
goToPrevPage={pageInfo.prevPage}
goToNextPage={pageInfo.nextPage}
/> />
</div> <div class="pagination">
</Layout> <Pagination
page={$fetch.pageNumber + 1}
hasPrevPage={$fetch.loading ? false : $fetch.hasPrevPage}
hasNextPage={$fetch.loading ? false : $fetch.hasNextPage}
goToPrevPage={fetch.prevPage}
goToNextPage={fetch.nextPage}
/>
</div>
</Layout>
{/if}
<Modal bind:this={createUserModal}> <Modal bind:this={createUserModal}>
<AddUserModal {showOnboardingTypeModal} /> <AddUserModal {showOnboardingTypeModal} />
</Modal> </Modal>
<Modal bind:this={inviteConfirmationModal}> <Modal bind:this={inviteConfirmationModal}>
<InvitedModal {inviteUsersResponse} /> <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>
</Modal> </Modal>
<Modal bind:this={onboardingTypeModal}> <Modal bind:this={onboardingTypeModal}>
@ -289,11 +290,10 @@
</Modal> </Modal>
<Modal bind:this={passwordModal}> <Modal bind:this={passwordModal}>
<PasswordModal {createUsersResponse} userData={userData.users} /> <PasswordModal
</Modal> createUsersResponse={bulkSaveResponse}
userData={userData.users}
<Modal bind:this={deletionFailureModal}> />
<DeletionFailureModal {deleteUsersResponse} />
</Modal> </Modal>
<Modal bind:this={importUsersModal}> <Modal bind:this={importUsersModal}>
@ -313,6 +313,7 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
} }
.controls-right { .controls-right {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -320,6 +321,7 @@
align-items: center; align-items: center;
gap: var(--spacing-xl); gap: var(--spacing-xl);
} }
.controls-right :global(.spectrum-Search) { .controls-right :global(.spectrum-Search) {
width: 200px; width: 200px;
} }

View file

@ -23,7 +23,7 @@
import AccessTab from "../_components/AccessTab.svelte" import AccessTab from "../_components/AccessTab.svelte"
import { API } from "api" import { API } from "api"
import { store } from "builderStore" import { store } from "builderStore"
import { apps, auth } from "stores/portal" import { apps, auth, groups } from "stores/portal"
import analytics, { Events, EventSource } from "analytics" import analytics, { Events, EventSource } from "analytics"
import { AppStatus } from "constants" import { AppStatus } from "constants"
import AppLockModal from "components/common/AppLockModal.svelte" import AppLockModal from "components/common/AppLockModal.svelte"
@ -36,17 +36,21 @@
export let application export let application
let promise = getPackage()
let loaded = false let loaded = false
let deletionModal let deletionModal
let unpublishModal let unpublishModal
let exportModal let exportModal
let appName = "" let appName = ""
let deployments = []
let published let published
// App // App
$: filteredApps = $apps.filter(app => app.devId === application) $: filteredApps = $apps.filter(app => app.devId === application)
$: selectedApp = filteredApps?.length ? filteredApps[0] : null $: selectedApp = filteredApps?.length ? filteredApps[0] : null
$: loaded && !selectedApp && backToAppList()
$: isPublished =
selectedApp?.status === AppStatus.DEPLOYED && latestDeployments?.length > 0
$: appUrl = `${window.origin}/app${selectedApp?.url}`
// Locking // Locking
$: lockedBy = selectedApp?.lockedBy $: lockedBy = selectedApp?.lockedBy
@ -58,18 +62,11 @@
}` }`
// App deployments // App deployments
$: deployments = []
$: latestDeployments = deployments $: latestDeployments = deployments
.filter( .filter(x => x.status === "SUCCESS" && application === x.appId)
deployment =>
deployment.status === "SUCCESS" && application === deployment.appId
)
.sort((a, b) => a.updatedAt > b.updatedAt) .sort((a, b) => a.updatedAt > b.updatedAt)
$: isPublished = // Tabs
selectedApp?.status === AppStatus.DEPLOYED && latestDeployments?.length > 0
$: appUrl = `${window.origin}/app${selectedApp?.url}`
$: tabs = ["Overview", "Automation History", "Backups", "Settings", "Access"] $: tabs = ["Overview", "Automation History", "Backups", "Settings", "Access"]
$: selectedTab = "Overview" $: selectedTab = "Overview"
@ -87,17 +84,6 @@
} }
} }
async function getPackage() {
try {
const pkg = await API.fetchAppPackage(application)
await store.actions.initialise(pkg)
loaded = true
return pkg
} catch (error) {
notifications.error(`Error initialising app: ${error?.message}`)
}
}
const reviewPendingDeployments = (deployments, newDeployments) => { const reviewPendingDeployments = (deployments, newDeployments) => {
if (deployments.length > 0) { if (deployments.length > 0) {
const pending = checkIncomingDeploymentStatus(deployments, newDeployments) const pending = checkIncomingDeploymentStatus(deployments, newDeployments)
@ -187,24 +173,37 @@
appName = null appName = null
} }
onDestroy(() => {
store.actions.reset()
})
onMount(async () => { onMount(async () => {
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
if (params.get("tab")) { if (params.get("tab")) {
selectedTab = params.get("tab") selectedTab = params.get("tab")
} }
// Check app exists
try { try {
const pkg = await API.fetchAppPackage(application)
await store.actions.initialise(pkg)
} catch (error) {
// Swallow
backToAppList()
}
// Initialise application
try {
await API.syncApp(application)
deployments = await fetchDeployments()
await groups.actions.init()
if (!apps.length) { if (!apps.length) {
await apps.load() await apps.load()
} }
await API.syncApp(application)
deployments = await fetchDeployments()
} catch (error) { } catch (error) {
notifications.error("Error initialising app overview") notifications.error("Error initialising app overview")
} }
loaded = true
})
onDestroy(() => {
store.actions.reset()
}) })
</script> </script>
@ -214,11 +213,11 @@
<span class="overview-wrap"> <span class="overview-wrap">
<Page wide noPadding> <Page wide noPadding>
{#await promise} {#if !loaded || !selectedApp}
<div class="loading"> <div class="loading">
<ProgressCircle size="XL" /> <ProgressCircle size="XL" />
</div> </div>
{:then _} {:else}
<Layout paddingX="XXL" paddingY="XL" gap="L"> <Layout paddingX="XXL" paddingY="XL" gap="L">
<span class="page-header" class:loaded> <span class="page-header" class:loaded>
<ActionButton secondary icon={"ArrowLeft"} on:click={backToAppList}> <ActionButton secondary icon={"ArrowLeft"} on:click={backToAppList}>
@ -360,9 +359,7 @@
> >
Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>? Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>?
</ConfirmDialog> </ConfirmDialog>
{:catch error} {/if}
<p>Something went wrong: {error.message}</p>
{/await}
</Page> </Page>
</span> </span>

View file

@ -14,55 +14,38 @@
import { onMount } from "svelte" import { onMount } from "svelte"
import RoleSelect from "components/common/RoleSelect.svelte" import RoleSelect from "components/common/RoleSelect.svelte"
import { users, groups, apps, auth } from "stores/portal" import { users, groups, apps, licensing } from "stores/portal"
import AssignmentModal from "./AssignmentModal.svelte" import AssignmentModal from "./AssignmentModal.svelte"
import { createPaginationStore } from "helpers/pagination"
import { roles } from "stores/backend" import { roles } from "stores/backend"
import { API } from "api"
import { fetchData } from "@budibase/frontend-core"
export let app export let app
let assignmentModal const usersFetch = fetchData({
let appGroups = [] API,
let appUsers = [] datasource: {
let prevSearch = undefined, type: "user",
search = undefined },
let pageInfo = createPaginationStore() options: {
let fixedAppId query: {
appId: apps.getProdAppID(app.devId),
$: page = $pageInfo.page },
$: fixedAppId = apps.getProdAppID(app.devId) },
$: appGroups = $groups.filter(x => {
return x.apps.includes(app.appId)
}) })
async function addData(appData) { let assignmentModal
let gr_prefix = "gr" let appGroups
let us_prefix = "us" let appUsers
appData.forEach(async data => {
if (data.id.startsWith(gr_prefix)) {
let matchedGroup = $groups.find(group => {
return group._id === data.id
})
matchedGroup.apps.push(app.appId)
matchedGroup.roles[fixedAppId] = data.role
groups.actions.save(matchedGroup) $: fixedAppId = apps.getProdAppID(app.devId)
} else if (data.id.startsWith(us_prefix)) { $: appUsers = $usersFetch.rows
let matchedUser = $users.data.find(user => { $: appGroups = $groups.filter(group => {
return user._id === data.id if (!group.roles) {
}) return false
}
let newUser = { return groups.actions.getGroupAppIds(group).includes(fixedAppId)
...matchedUser, })
roles: { [fixedAppId]: data.role, ...matchedUser.roles },
}
await users.save(newUser, { opts: { appId: fixedAppId } })
await fetchUsers(page, search)
}
})
await groups.actions.init()
}
async function removeUser(user) { async function removeUser(user) {
// Remove the user role // Remove the user role
@ -74,67 +57,27 @@
...filteredRoles, ...filteredRoles,
}, },
}) })
await fetchUsers(page, search) await usersFetch.refresh()
} }
async function removeGroup(group) { async function removeGroup(group) {
// Remove the user role await groups.actions.removeApp(group._id, fixedAppId)
let filteredApps = group.apps.filter( await groups.actions.init()
x => apps.extractAppId(x) !== app.appId await usersFetch.refresh()
)
const filteredRoles = { ...group.roles }
delete filteredRoles[fixedAppId]
await groups.actions.save({
...group,
apps: filteredApps,
roles: { ...filteredRoles },
})
await fetchUsers(page, search)
} }
async function updateUserRole(role, user) { async function updateUserRole(role, user) {
user.roles[fixedAppId] = role user.roles[fixedAppId] = role
users.save(user) await users.save(user)
} }
async function updateGroupRole(role, group) { async function updateGroupRole(role, group) {
group.roles[fixedAppId] = role await groups.actions.addApp(group._id, fixedAppId, role)
groups.actions.save(group) await usersFetch.refresh()
}
async function fetchUsers(page, search) {
if ($pageInfo.loading) {
return
}
// need to remove the page if they've started searching
if (search && !prevSearch) {
pageInfo.reset()
page = undefined
}
prevSearch = search
try {
pageInfo.loading()
await users.search({ page, appId: fixedAppId })
pageInfo.fetched($users.hasNextPage, $users.nextPage)
appUsers =
$users.data?.filter(x => {
return Object.keys(x.roles).find(y => {
return y === fixedAppId
})
}) || []
} catch (error) {
notifications.error("Error getting user list")
}
} }
onMount(async () => { onMount(async () => {
try { try {
await fetchUsers(page, search)
await groups.actions.init()
await apps.load()
await roles.fetch() await roles.fetch()
} catch (error) { } catch (error) {
notifications.error(error) notifications.error(error)
@ -149,14 +92,14 @@
<Heading>Access</Heading> <Heading>Access</Heading>
<div class="subtitle"> <div class="subtitle">
<Body size="S"> <Body size="S">
Assign users to your app and define their access here</Body Assign users and groups to your app and define their access here
> </Body>
<Button on:click={assignmentModal.show} icon="User" cta <Button on:click={assignmentModal.show} icon="User" cta>
>Assign users</Button Assign access
> </Button>
</div> </div>
</div> </div>
{#if $auth.groupsEnabled && appGroups.length} {#if $licensing.groupsEnabled && appGroups.length}
<List title="User Groups"> <List title="User Groups">
{#each appGroups as group} {#each appGroups as group}
<ListItem <ListItem
@ -169,8 +112,11 @@
autoWidth autoWidth
quiet quiet
value={group.roles[ value={group.roles[
Object.keys(group.roles).find(x => x === fixedAppId) groups.actions
.getGroupAppIds(group)
.find(x => x === fixedAppId)
]} ]}
allowPublic={false}
/> />
<Icon <Icon
on:click={() => removeGroup(group)} on:click={() => removeGroup(group)}
@ -183,40 +129,37 @@
</List> </List>
{/if} {/if}
{#if appUsers.length} {#if appUsers.length}
<List title="Users"> <div>
{#each appUsers as user} <List title="Users">
<ListItem title={user.email} avatar> {#each appUsers as user}
<RoleSelect <ListItem title={user.email} avatar>
on:change={e => updateUserRole(e.detail, user)} <RoleSelect
autoWidth on:change={e => updateUserRole(e.detail, user)}
quiet autoWidth
value={user.roles[ quiet
Object.keys(user.roles).find(x => x === fixedAppId) value={user.roles[
]} Object.keys(user.roles).find(x => x === fixedAppId)
/> ]}
<Icon allowPublic={false}
on:click={() => removeUser(user)} />
hoverable <Icon
size="S" on:click={() => removeUser(user)}
name="Close" hoverable
/> size="S"
</ListItem> name="Close"
{/each} />
</List> </ListItem>
<div class="pagination"> {/each}
<Pagination </List>
page={$pageInfo.pageNumber} <div class="pagination">
hasPrevPage={$pageInfo.loading ? false : $pageInfo.hasPrevPage} <Pagination
hasNextPage={$pageInfo.loading ? false : $pageInfo.hasNextPage} page={$usersFetch.pageNumber + 1}
goToPrevPage={async () => { hasPrevPage={$usersFetch.hasPrevPage}
await pageInfo.prevPage() hasNextPage={$usersFetch.hasNextPage}
fetchUsers(page, search) goToPrevPage={$usersFetch.loading ? null : fetch.prevPage}
}} goToNextPage={$usersFetch.loading ? null : fetch.nextPage}
goToNextPage={async () => { />
await pageInfo.nextPage() </div>
fetchUsers(page, search)
}}
/>
</div> </div>
{/if} {/if}
{:else} {:else}
@ -224,14 +167,18 @@
<Layout gap="S"> <Layout gap="S">
<Heading>No users assigned</Heading> <Heading>No users assigned</Heading>
<div class="opacity"> <div class="opacity">
<Body size="S" <Body size="S">
>Assign users to your app and set their access here</Body Assign users/groups to your app and set their access here
> </Body>
</div> </div>
<div class="padding"> <div class="padding">
<Button on:click={() => assignmentModal.show()} cta icon="UserArrow" <Button
>Assign Users</Button on:click={() => assignmentModal.show()}
cta
icon="UserArrow"
> >
Assign access
</Button>
</div> </div>
</Layout> </Layout>
</div> </div>
@ -240,7 +187,7 @@
</div> </div>
<Modal bind:this={assignmentModal}> <Modal bind:this={assignmentModal}>
<AssignmentModal {app} {appUsers} {addData} /> <AssignmentModal {app} {appUsers} on:update={usersFetch.refresh} />
</Modal> </Modal>
<style> <style>

View file

@ -5,37 +5,46 @@
ActionButton, ActionButton,
Layout, Layout,
Icon, Icon,
notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import { roles } from "stores/backend" import { roles } from "stores/backend"
import { groups, users, auth } from "stores/portal" import { groups, users, licensing, apps } from "stores/portal"
import { RoleUtils } from "@budibase/frontend-core" import { Constants, RoleUtils, fetchData } from "@budibase/frontend-core"
import { createPaginationStore } from "helpers/pagination" import { API } from "api"
import { createEventDispatcher } from "svelte"
export let app export let app
export let addData
export let appUsers = [] export let appUsers = []
let prevSearch = undefined, const dispatch = createEventDispatcher()
search = undefined const usersFetch = fetchData({
let pageInfo = createPaginationStore() API,
let appData = [{ id: "", role: "" }] datasource: {
type: "user",
$: page = $pageInfo.page },
$: fetchUsers(page, search) options: {
$: availableUsers = getAvailableUsers($users, appUsers, appData) query: {
$: filteredGroups = $groups.filter(group => { email: "",
return !group.apps.find(appId => { },
return appId === app.appId },
})
}) })
$: valid =
appData?.length && !appData?.some(x => !x.id?.length || !x.role?.length) let search = ""
let data = [{ id: "", role: "" }]
$: usersFetch.update({
query: {
email: search,
},
})
$: fixedAppId = apps.getProdAppID(app.devId)
$: availableUsers = getAvailableUsers($usersFetch.rows, appUsers, data)
$: availableGroups = getAvailableGroups($groups, app.appId, search, data)
$: valid = data?.length && !data?.some(x => !x.id?.length || !x.role?.length)
$: optionSections = { $: optionSections = {
...($auth.groupsEnabled && ...($licensing.groupsEnabled &&
filteredGroups.length && { availableGroups.length && {
["User groups"]: { ["User groups"]: {
data: filteredGroups, data: availableGroups,
getLabel: group => group.name, getLabel: group => group.name,
getValue: group => group._id, getValue: group => group._id,
getIcon: group => group.icon, getIcon: group => group.icon,
@ -51,8 +60,45 @@
}, },
} }
const addData = async appData => {
const gr_prefix = "gr"
const us_prefix = "us"
for (let data of appData) {
// Assign group
if (data.id.startsWith(gr_prefix)) {
const group = $groups.find(group => {
return group._id === data.id
})
if (!group) {
continue
}
await groups.actions.addApp(group._id, fixedAppId, data.role)
}
// Assign user
else if (data.id.startsWith(us_prefix)) {
const user = await users.get(data.id)
await users.save({
...user,
roles: {
...user.roles,
[fixedAppId]: data.role,
},
})
}
}
// Refresh data when completed
await usersFetch.refresh()
dispatch("update")
}
const getAvailableUsers = (allUsers, appUsers, newUsers) => { const getAvailableUsers = (allUsers, appUsers, newUsers) => {
return (allUsers.data || []).filter(user => { return (allUsers || []).filter(user => {
// Filter out admin users
if (user?.admin?.global || user?.builder?.global) {
return false
}
// Filter out assigned users // Filter out assigned users
if (appUsers.find(x => x._id === user._id)) { if (appUsers.find(x => x._id === user._id)) {
return false return false
@ -63,31 +109,31 @@
}) })
} }
async function fetchUsers(page, search) { const getAvailableGroups = (allGroups, appId, search, newGroups) => {
if ($pageInfo.loading) { search = search?.toLowerCase()
return return (allGroups || []).filter(group => {
} // Filter out assigned groups
// need to remove the page if they've started searching const appIds = groups.actions.getGroupAppIds(group)
if (search && !prevSearch) { if (appIds.includes(apps.getProdAppID(appId))) {
pageInfo.reset() return false
page = undefined }
}
prevSearch = search // Filter out new groups which are going to be assigned
try { if (newGroups.find(x => x.id === group._id)) {
pageInfo.loading() return false
await users.search({ page, email: search }) }
pageInfo.fetched($users.hasNextPage, $users.nextPage)
} catch (error) { // Match search string
notifications.error("Error getting user list") return !search || group.name.toLowerCase().includes(search)
} })
} }
function addNewInput() { function addNewInput() {
appData = [...appData, { id: "", role: "" }] data = [...data, { id: "", role: "" }]
} }
const removeItem = index => { const removeItem = index => {
appData = appData.filter((x, idx) => idx !== index) data = data.filter((x, idx) => idx !== index)
} }
</script> </script>
@ -96,20 +142,22 @@
title="Assign users to your app" title="Assign users to your app"
confirmText="Done" confirmText="Done"
cancelText="Cancel" cancelText="Cancel"
onConfirm={() => addData(appData)} onConfirm={() => addData(data)}
showCloseIcon={false} showCloseIcon={false}
disabled={!valid} disabled={!valid}
> >
{#if appData?.length} {#if data.length}
<Layout noPadding gap="XS"> <Layout noPadding gap="XS">
{#each appData as input, index} {#each data as input, index}
<div class="item"> <div class="item">
<div class="picker"> <div class="picker">
<PickerDropdown <PickerDropdown
autocomplete autocomplete
showClearIcon={false} showClearIcon={false}
primaryOptions={optionSections} primaryOptions={optionSections}
secondaryOptions={$roles} secondaryOptions={$roles.filter(
x => x._id !== Constants.Roles.PUBLIC
)}
secondaryPlaceholder="Access" secondaryPlaceholder="Access"
bind:primaryValue={input.id} bind:primaryValue={input.id}
bind:secondaryValue={input.role} bind:secondaryValue={input.role}

View file

@ -5,30 +5,56 @@
import { store } from "builderStore" import { store } from "builderStore"
import clientPackage from "@budibase/client/package.json" import clientPackage from "@budibase/client/package.json"
import { processStringSync } from "@budibase/string-templates" import { processStringSync } from "@budibase/string-templates"
import { users, auth, apps } from "stores/portal" import { users, auth, apps, groups } from "stores/portal"
import { createEventDispatcher, onMount } from "svelte" import { createEventDispatcher } from "svelte"
import { fetchData } from "@budibase/frontend-core"
import { API } from "api"
import GroupIcon from "../../manage/groups/_components/GroupIcon.svelte"
export let app export let app
export let deployments export let deployments
export let navigateTab export let navigateTab
let userCount
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const appUsersFetch = fetchData({
API,
datasource: {
type: "user",
},
options: {
query: {
appId: apps.getProdAppID(app.devId),
},
},
})
let appEditor
$: updateAvailable = clientPackage.version !== $store.version
$: isPublished = app?.status === AppStatus.DEPLOYED
$: appEditorId = !app?.updatedBy ? $auth.user._id : app?.updatedBy
$: appEditorText = appEditor?.firstName || appEditor?.email
$: fetchAppEditor(appEditorId)
$: appUsers = $appUsersFetch.rows || []
$: appUsersFetch.update({
query: {
appId: apps.getProdAppID(app.devId),
},
})
$: prodAppId = apps.getProdAppID(app.devId)
$: appGroups = $groups.filter(group => {
if (!group.roles) {
return false
}
return groups.actions.getGroupAppIds(group).includes(prodAppId)
})
const unpublishApp = () => { const unpublishApp = () => {
dispatch("unpublish", app) dispatch("unpublish", app)
} }
let appEditor, appEditorPromise
$: updateAvailable = clientPackage.version !== $store.version
$: isPublished = app && app?.status === AppStatus.DEPLOYED
$: appEditorId = !app?.updatedBy ? $auth.user._id : app?.updatedBy
$: appEditorText = appEditor?.firstName || appEditor?.email
$: fetchAppEditor(appEditorId)
async function fetchAppEditor(editorId) { async function fetchAppEditor(editorId) {
appEditorPromise = users.get(editorId) appEditor = await users.get(editorId)
appEditor = await appEditorPromise
} }
const getInitials = user => { const getInitials = user => {
@ -36,16 +62,8 @@
initials += user.firstName ? user.firstName[0] : "" initials += user.firstName ? user.firstName[0] : ""
initials += user.lastName ? user.lastName[0] : "" initials += user.lastName ? user.lastName[0] : ""
return initials == "" ? user.email[0] : initials return initials === "" ? user.email[0] : initials
} }
onMount(async () => {
let resp = await users.getUserCountByApp({
appId: apps.getProdAppID(app.devId),
})
userCount = resp.userCount
await users.search({ appId: apps.getProdAppID(app.devId), limit: 4 })
})
</script> </script>
<div class="overview-tab"> <div class="overview-tab">
@ -83,11 +101,9 @@
</div> </div>
</div> </div>
</DashCard> </DashCard>
<DashCard title={"Last Edited"} dataCy={"edited-by"}> {#if appEditor}
<div class="last-edited-content"> <DashCard title={"Last Edited"} dataCy={"edited-by"}>
{#await appEditorPromise} <div class="last-edited-content">
<Avatar size="M" initials={"-"} />
{:then _}
<div class="updated-by"> <div class="updated-by">
{#if appEditor} {#if appEditor}
<Avatar size="M" initials={getInitials(appEditor)} /> <Avatar size="M" initials={getInitials(appEditor)} />
@ -96,22 +112,20 @@
</div> </div>
{/if} {/if}
</div> </div>
{:catch error} <div class="last-edit-text">
<p>Could not fetch user: {error.message}</p> {#if app}
{/await} {processStringSync(
<div class="last-edit-text"> "Last edited {{ duration time 'millisecond' }} ago",
{#if app} {
{processStringSync( time:
"Last edited {{ duration time 'millisecond' }} ago", new Date().getTime() - new Date(app?.updatedAt).getTime(),
{ }
time: )}
new Date().getTime() - new Date(app?.updatedAt).getTime(), {/if}
} </div>
)}
{/if}
</div> </div>
</div> </DashCard>
</DashCard> {/if}
<DashCard <DashCard
title={"App Version"} title={"App Version"}
showIcon={true} showIcon={true}
@ -141,26 +155,44 @@
{/if} {/if}
</div> </div>
</DashCard> </DashCard>
<DashCard {#if $appUsersFetch.loaded}
title={"Access"} <DashCard
showIcon={true} title={"Access"}
action={() => { showIcon={true}
navigateTab("Access") action={() => {
}} navigateTab("Access")
dataCy={"access"} }}
> dataCy={"access"}
<div class="last-edited-content"> >
{#if $users?.data?.length} {#if appUsers.length || appGroups.length}
<Layout noPadding gap="S"> <Layout noPadding gap="S">
<div class="users-tab"> <div class="access-tab-content">
{#each $users?.data as user} {#if appUsers.length}
<Avatar size="M" initials={getInitials(user)} /> <div class="users">
{/each} <div class="list">
</div> {#each appUsers.slice(0, 4) as user}
<Avatar size="M" initials={getInitials(user)} />
<div class="users-text"> {/each}
{userCount} </div>
{userCount > 1 ? `users have` : `user has`} access to this app <div class="text">
{appUsers.length}
{appUsers.length > 1 ? "users" : "user"} assigned
</div>
</div>
{/if}
{#if appGroups.length}
<div class="groups">
<div class="list">
{#each appGroups.slice(0, 4) as group}
<GroupIcon {group} />
{/each}
</div>
<div class="text">
{appGroups.length} user
{appGroups.length > 1 ? "groups" : "group"} assigned
</div>
</div>
{/if}
</div> </div>
</Layout> </Layout>
{:else} {:else}
@ -171,8 +203,8 @@
</div> </div>
</Layout> </Layout>
{/if} {/if}
</div> </DashCard>
</DashCard> {/if}
</div> </div>
{#if false} {#if false}
<div class="bottom"> <div class="bottom">
@ -224,17 +256,29 @@
.overview-tab .top { .overview-tab .top {
display: grid; display: grid;
grid-gap: var(--spectrum-alias-grid-gutter-medium); grid-gap: var(--spectrum-alias-grid-gutter-medium);
grid-template-columns: repeat(auto-fill, minmax(30%, 1fr)); grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
} }
.users-tab { .access-tab-content {
display: flex; display: flex;
gap: var(--spacing-m); flex-direction: row;
justify-content: flex-start;
align-items: flex-start;
gap: var(--spacing-xl);
flex-wrap: wrap;
}
.access-tab-content > * {
flex: 1 1 0;
}
.access-tab-content .list {
display: flex;
gap: 4px;
}
.access-tab-content .text {
color: var(--spectrum-global-color-gray-600);
margin-top: var(--spacing-xl);
} }
.users-text {
color: var(--spectrum-global-color-gray-600);
}
.overview-tab .bottom, .overview-tab .bottom,
.automation-metrics { .automation-metrics {
display: grid; display: grid;
@ -242,23 +286,6 @@
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
} }
@media (max-width: 1000px) {
.overview-tab .top {
grid-template-columns: 1fr 1fr;
}
.overview-tab .bottom {
grid-template-columns: 1fr;
}
}
@media (max-width: 800px) {
.overview-tab .top,
.overview-tab .bottom {
grid-template-columns: 1fr;
}
}
.status-display { .status-display {
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -21,7 +21,7 @@
$: updateAvailable = clientPackage.version !== $store.version $: updateAvailable = clientPackage.version !== $store.version
$: appUrl = `${window.origin}/app${app?.url}` $: appUrl = `${window.origin}/app${app?.url}`
$: appDeployed = app.status === AppStatus.DEPLOYED $: appDeployed = app?.status === AppStatus.DEPLOYED
</script> </script>
<div class="settings-tab"> <div class="settings-tab">

View file

@ -83,7 +83,7 @@
analytics. analytics.
</Body> </Body>
</Layout> </Layout>
<Divider size="S" /> <Divider />
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
<Heading size="S">Information</Heading> <Heading size="S">Information</Heading>
<Body size="S">Here you can update your logo and organization name.</Body> <Body size="S">Here you can update your logo and organization name.</Body>
@ -110,7 +110,7 @@
</div> </div>
</div> </div>
{#if !$admin.cloud} {#if !$admin.cloud}
<Divider size="S" /> <Divider />
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
<Heading size="S">Platform</Heading> <Heading size="S">Platform</Heading>
<Body size="S">Here you can set up general platform settings.</Body> <Body size="S">Here you can set up general platform settings.</Body>
@ -128,7 +128,7 @@
</div> </div>
{/if} {/if}
{#if !$admin.cloud} {#if !$admin.cloud}
<Divider size="S" /> <Divider />
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
<Heading size="S">Analytics</Heading> <Heading size="S">Analytics</Heading>
<Body size="S">Choose whether to opt-in or opt-out of analytics.</Body> <Body size="S">Choose whether to opt-in or opt-out of analytics.</Body>

View file

@ -9,7 +9,7 @@
<Heading size="M">Theming</Heading> <Heading size="M">Theming</Heading>
<Body>Customize how Budibase looks and feels.</Body> <Body>Customize how Budibase looks and feels.</Body>
</Layout> </Layout>
<Divider size="S" /> <Divider />
<div class="fields"> <div class="fields">
<div class="field"> <div class="field">
<Label size="L">Builder theme</Label> <Label size="L">Builder theme</Label>

View file

@ -60,7 +60,7 @@
latest features, security updates and much more. latest features, security updates and much more.
</Body> </Body>
</Layout> </Layout>
<Divider size="S" /> <Divider />
{#if version} {#if version}
<div> <div>
<Label size="L">Current version</Label> <Label size="L">Current version</Label>

View file

@ -13,6 +13,7 @@
import { auth, admin } from "stores/portal" import { auth, admin } from "stores/portal"
import { redirect } from "@roxi/routify" import { redirect } from "@roxi/routify"
import { processStringSync } from "@budibase/string-templates" import { processStringSync } from "@budibase/string-templates"
import DeleteLicenseKeyModal from "../../../../components/portal/licensing/DeleteLicenseKeyModal.svelte"
import { API } from "api" import { API } from "api"
import { onMount } from "svelte" import { onMount } from "svelte"
@ -26,6 +27,7 @@
let licenseKeyDisabled = false let licenseKeyDisabled = false
let licenseKeyType = "text" let licenseKeyType = "text"
let licenseKey = "" let licenseKey = ""
let deleteLicenseKeyModal
// Make sure page can't be visited directly in cloud // Make sure page can't be visited directly in cloud
$: { $: {
@ -45,6 +47,20 @@
} }
} }
const destroy = async () => {
try {
await API.deleteLicenseKey({ licenseKey })
await auth.getSelf()
await setLicenseInfo()
// reset the form
licenseKey = ""
licenseKeyDisabled = false
notifications.success("Successfully deleted")
} catch (e) {
notifications.error(e.message)
}
}
const refresh = async () => { const refresh = async () => {
try { try {
await API.refreshLicense() await API.refreshLicense()
@ -76,23 +92,25 @@
</script> </script>
{#if $auth.isAdmin} {#if $auth.isAdmin}
<DeleteLicenseKeyModal
bind:this={deleteLicenseKeyModal}
onConfirm={destroy}
/>
<Layout noPadding> <Layout noPadding>
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
<Heading size="M">Upgrade</Heading> <Heading size="M">Upgrade</Heading>
<Body size="M"> <Body size="M">
{#if license.plan.type === "free"} {#if license.plan.type === "free"}
Upgrade your budibase installation to unlock additional features. To Upgrade your Budibase installation to unlock additional features. To
subscribe to a plan visit your <Link size="L" href={upgradeUrl} subscribe to a plan visit your
>Account</Link <Link size="L" href={upgradeUrl}>Account</Link>.
>.
{:else} {:else}
To manage your plan visit your <Link size="L" href={upgradeUrl} To manage your plan visit your
>Account</Link <Link size="L" href={upgradeUrl}>Account</Link>.
>.
{/if} {/if}
</Body> </Body>
</Layout> </Layout>
<Divider size="S" /> <Divider />
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
<Heading size="S">Activate</Heading> <Heading size="S">Activate</Heading>
<Body size="S">Enter your license key below to activate your plan</Body> <Body size="S">Enter your license key below to activate your plan</Body>
@ -100,7 +118,7 @@
<Layout noPadding> <Layout noPadding>
<div class="fields"> <div class="fields">
<div class="field"> <div class="field">
<Label size="L">License Key</Label> <Label size="L">License key</Label>
<Input <Input
thin thin
bind:value={licenseKey} bind:value={licenseKey}
@ -109,13 +127,22 @@
/> />
</div> </div>
</div> </div>
<div> <div class="button-container">
<Button cta on:click={activate} disabled={activateDisabled} <div class="action-button">
>Activate</Button <Button cta on:click={activate} disabled={activateDisabled}
> >Activate</Button
>
</div>
<div class="action-button">
{#if licenseInfo?.licenseKey}
<Button warning on:click={() => deleteLicenseKeyModal.show()}
>Delete</Button
>
{/if}
</div>
</div> </div>
</Layout> </Layout>
<Divider size="S" /> <Divider />
<Layout gap="L" noPadding> <Layout gap="L" noPadding>
<Layout gap="S" noPadding> <Layout gap="S" noPadding>
<Heading size="S">Plan</Heading> <Heading size="S">Plan</Heading>
@ -152,4 +179,10 @@
grid-gap: var(--spacing-l); grid-gap: var(--spacing-l);
align-items: center; align-items: center;
} }
.action-button {
margin-right: 10px;
}
.button-container {
display: flex;
}
</style> </style>

View file

@ -147,7 +147,8 @@
const init = async () => { const init = async () => {
try { try {
await licensing.getQuotaUsage() // always load latest
await licensing.init()
} catch (e) { } catch (e) {
console.error(e) console.error(e)
notifications.error(e) notifications.error(e)
@ -175,18 +176,18 @@
</script> </script>
{#if loaded} {#if loaded}
<Layout> <Layout noPadding>
<Layout noPadding gap="S"> <Layout noPadding gap="XS">
<Heading>Usage</Heading> <Heading>Usage</Heading>
<Body <Body>
>Get information about your current usage within Budibase. Get information about your current usage within Budibase.
{#if accountPortalAccess} {#if accountPortalAccess}
To upgrade your plan and usage limits visit your <Link To upgrade your plan and usage limits visit your <Link
on:click={goToAccountPortal} on:click={goToAccountPortal}
size="L">Account</Link size="L">Account</Link
> >
{:else} {:else}
To upgrade your plan and usage limits contact your account holder To upgrade your plan and usage limits contact your account holder.
{/if} {/if}
</Body> </Body>
</Layout> </Layout>

View file

@ -8,14 +8,21 @@ const extractAppId = id => {
} }
const getProdAppID = appId => { const getProdAppID = appId => {
if (!appId || !appId.startsWith("app_dev")) { if (!appId) {
return appId return appId
} }
// split to take off the app_dev element, then join it together incase any other app_ exist let rest,
const split = appId.split("app_dev") separator = ""
split.shift() if (appId.startsWith("app_dev")) {
const rest = split.join("app_dev") // split to take off the app_dev element, then join it together incase any other app_ exist
return `${"app"}${rest}` const split = appId.split("app_dev")
split.shift()
rest = split.join("app_dev")
} else if (!appId.startsWith("app")) {
rest = appId
separator = "_"
}
return `app${separator}${rest}`
} }
export function createAppStore() { export function createAppStore() {

View file

@ -2,23 +2,20 @@ import { derived, writable, get } from "svelte/store"
import { API } from "api" import { API } from "api"
import { admin } from "stores/portal" import { admin } from "stores/portal"
import analytics from "analytics" import analytics from "analytics"
import { FEATURE_FLAGS } from "helpers/featureFlags"
import { Constants } from "@budibase/frontend-core"
export function createAuthStore() { export function createAuthStore() {
const auth = writable({ const auth = writable({
user: null, user: null,
accountPortalAccess: false,
tenantId: "default", tenantId: "default",
tenantSet: false, tenantSet: false,
loaded: false, loaded: false,
postLogout: false, postLogout: false,
groupsEnabled: false,
}) })
const store = derived(auth, $store => { const store = derived(auth, $store => {
let initials = null let initials = null
let isAdmin = false let isAdmin = false
let isBuilder = false let isBuilder = false
let groupsEnabled = false
if ($store.user) { if ($store.user) {
const user = $store.user const user = $store.user
if (user.firstName) { if (user.firstName) {
@ -33,12 +30,10 @@ export function createAuthStore() {
} }
isAdmin = !!user.admin?.global isAdmin = !!user.admin?.global
isBuilder = !!user.builder?.global isBuilder = !!user.builder?.global
groupsEnabled =
user?.license.features.includes(Constants.Features.USER_GROUPS) &&
user?.featureFlags.includes(FEATURE_FLAGS.USER_GROUPS)
} }
return { return {
user: $store.user, user: $store.user,
accountPortalAccess: $store.accountPortalAccess,
tenantId: $store.tenantId, tenantId: $store.tenantId,
tenantSet: $store.tenantSet, tenantSet: $store.tenantSet,
loaded: $store.loaded, loaded: $store.loaded,
@ -46,7 +41,6 @@ export function createAuthStore() {
initials, initials,
isAdmin, isAdmin,
isBuilder, isBuilder,
groupsEnabled,
} }
}) })
@ -54,6 +48,7 @@ export function createAuthStore() {
auth.update(store => { auth.update(store => {
store.loaded = true store.loaded = true
store.user = user store.user = user
store.accountPortalAccess = user?.accountPortalAccess
if (user) { if (user) {
store.tenantId = user.tenantId || "default" store.tenantId = user.tenantId || "default"
store.tenantSet = true store.tenantSet = true

View file

@ -1,36 +1,45 @@
import { writable, get } from "svelte/store" import { writable, get } from "svelte/store"
import { API } from "api" import { API } from "api"
import { auth } from "stores/portal" import { licensing } from "stores/portal"
import { Constants } from "@budibase/frontend-core"
export function createGroupsStore() { export function createGroupsStore() {
const store = writable([]) const store = writable([])
const updateStore = group => {
store.update(state => {
const currentIdx = state.findIndex(gr => gr._id === group._id)
if (currentIdx >= 0) {
state.splice(currentIdx, 1, group)
} else {
state.push(group)
}
return state
})
}
const getGroup = async groupId => {
const group = await API.getGroup(groupId)
updateStore(group)
}
const actions = { const actions = {
init: async () => { init: async () => {
// only init if these is a groups license, just to be sure but the feature will be blocked // only init if there is a groups license, just to be sure but the feature will be blocked
// on the backend anyway // on the backend anyway
if ( if (get(licensing).groupsEnabled) {
get(auth).user.license.features.includes(Constants.Features.USER_GROUPS) const groups = await API.getGroups()
) { store.set(groups)
const users = await API.getGroups()
store.set(users)
} }
}, },
get: getGroup,
save: async group => { save: async group => {
const response = await API.saveGroup(group) const response = await API.saveGroup(group)
group._id = response._id group._id = response._id
group._rev = response._rev group._rev = response._rev
store.update(state => { updateStore(group)
const currentIdx = state.findIndex(gr => gr._id === response._id) return group
if (currentIdx >= 0) {
state.splice(currentIdx, 1, group)
} else {
state.push(group)
}
return state
})
}, },
delete: async group => { delete: async group => {
@ -43,6 +52,34 @@ export function createGroupsStore() {
return state return state
}) })
}, },
addUser: async (groupId, userId) => {
await API.addUsersToGroup(groupId, userId)
// refresh the group enrichment
await getGroup(groupId)
},
removeUser: async (groupId, userId) => {
await API.removeUsersFromGroup(groupId, userId)
// refresh the group enrichment
await getGroup(groupId)
},
addApp: async (groupId, appId, roleId) => {
await API.addAppsToGroup(groupId, [{ appId, roleId }])
// refresh the group roles
await getGroup(groupId)
},
removeApp: async (groupId, appId) => {
await API.removeAppsFromGroup(groupId, [{ appId }])
// refresh the group roles
await getGroup(groupId)
},
getGroupAppIds: group => {
return Object.keys(group?.roles || {})
},
} }
return { return {

View file

@ -1,14 +1,31 @@
import { writable, get } from "svelte/store" import { writable, get } from "svelte/store"
import { API } from "api" import { API } from "api"
import { auth } from "stores/portal" import { auth, admin } from "stores/portal"
import { Constants } from "@budibase/frontend-core" import { Constants } from "@budibase/frontend-core"
import { StripeStatus } from "components/portal/licensing/constants" import { StripeStatus } from "components/portal/licensing/constants"
import { FEATURE_FLAGS, isEnabled } from "../../helpers/featureFlags" import { TENANT_FEATURE_FLAGS, isEnabled } from "helpers/featureFlags"
export const createLicensingStore = () => { export const createLicensingStore = () => {
const DEFAULT = { const DEFAULT = {
plans: {}, // navigation
usageMetrics: {}, goToUpgradePage: () => {},
// the top level license
license: undefined,
isFreePlan: true,
// features
groupsEnabled: false,
// the currently used quotas from the db
quotaUsage: undefined,
// derived quota metrics for percentages used
usageMetrics: undefined,
// quota reset
quotaResetDaysRemaining: undefined,
quotaResetDate: undefined,
// failed payments
accountPastDue: undefined,
pastDueEndDate: undefined,
pastDueDaysRemaining: undefined,
accountDowngraded: undefined,
} }
const oneDayInMilliseconds = 86400000 const oneDayInMilliseconds = 86400000
@ -16,10 +33,39 @@ export const createLicensingStore = () => {
const actions = { const actions = {
init: async () => { init: async () => {
await actions.getQuotaUsage() actions.setNavigation()
await actions.getUsageMetrics() actions.setLicense()
await actions.setQuotaUsage()
actions.setUsageMetrics()
}, },
getQuotaUsage: async () => { setNavigation: () => {
const upgradeUrl = `${get(admin).accountPortalUrl}/portal/upgrade`
const goToUpgradePage = () => {
window.location.href = upgradeUrl
}
store.update(state => {
return {
...state,
goToUpgradePage,
}
})
},
setLicense: () => {
const license = get(auth).user.license
const isFreePlan = license?.plan.type === Constants.PlanType.FREE
const groupsEnabled = license.features.includes(
Constants.Features.USER_GROUPS
)
store.update(state => {
return {
...state,
license,
isFreePlan,
groupsEnabled,
}
})
},
setQuotaUsage: async () => {
const quotaUsage = await API.getQuotaUsage() const quotaUsage = await API.getQuotaUsage()
store.update(state => { store.update(state => {
return { return {
@ -28,8 +74,8 @@ export const createLicensingStore = () => {
} }
}) })
}, },
getUsageMetrics: async () => { setUsageMetrics: () => {
if (isEnabled(FEATURE_FLAGS.LICENSING)) { if (isEnabled(TENANT_FEATURE_FLAGS.LICENSING)) {
const quota = get(store).quotaUsage const quota = get(store).quotaUsage
const license = get(auth).user.license const license = get(auth).user.license
const now = new Date() const now = new Date()
@ -41,7 +87,7 @@ export const createLicensingStore = () => {
return keys.reduce((acc, key) => { return keys.reduce((acc, key) => {
const quotaLimit = license[key].value const quotaLimit = license[key].value
const quotaUsed = (quota[key] / quotaLimit) * 100 const quotaUsed = (quota[key] / quotaLimit) * 100
acc[key] = quotaLimit > -1 ? Math.round(quotaUsed) : -1 acc[key] = quotaLimit > -1 ? Math.floor(quotaUsed) : -1
return acc return acc
}, {}) }, {})
} }
@ -97,9 +143,6 @@ export const createLicensingStore = () => {
accountPastDue: pastDueAtMilliseconds != null, accountPastDue: pastDueAtMilliseconds != null,
pastDueEndDate, pastDueEndDate,
pastDueDaysRemaining, pastDueDaysRemaining,
isFreePlan: () => {
return license?.plan.type === Constants.PlanType.FREE
},
} }
}) })
} }

View file

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

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{ {
"name": "@budibase/client", "name": "@budibase/client",
"version": "1.4.17", "version": "1.4.18-alpha.0",
"license": "MPL-2.0", "license": "MPL-2.0",
"module": "dist/budibase-client.js", "module": "dist/budibase-client.js",
"main": "dist/budibase-client.js", "main": "dist/budibase-client.js",
@ -19,9 +19,9 @@
"dev:builder": "rollup -cw" "dev:builder": "rollup -cw"
}, },
"dependencies": { "dependencies": {
"@budibase/bbui": "^1.4.17", "@budibase/bbui": "1.4.18-alpha.0",
"@budibase/frontend-core": "^1.4.17", "@budibase/frontend-core": "1.4.18-alpha.0",
"@budibase/string-templates": "^1.4.17", "@budibase/string-templates": "1.4.18-alpha.0",
"@spectrum-css/button": "^3.0.3", "@spectrum-css/button": "^3.0.3",
"@spectrum-css/card": "^3.0.3", "@spectrum-css/card": "^3.0.3",
"@spectrum-css/divider": "^1.0.3", "@spectrum-css/divider": "^1.0.3",
@ -35,7 +35,6 @@
"downloadjs": "1.4.7", "downloadjs": "1.4.7",
"leaflet": "^1.7.1", "leaflet": "^1.7.1",
"regexparam": "^1.3.0", "regexparam": "^1.3.0",
"rollup-plugin-polyfill-node": "^0.8.0",
"sanitize-html": "^2.7.0", "sanitize-html": "^2.7.0",
"screenfull": "^6.0.1", "screenfull": "^6.0.1",
"shortid": "^2.2.15", "shortid": "^2.2.15",
@ -52,6 +51,7 @@
"postcss": "^8.2.10", "postcss": "^8.2.10",
"rollup": "^2.44.0", "rollup": "^2.44.0",
"rollup-plugin-json": "^4.0.0", "rollup-plugin-json": "^4.0.0",
"rollup-plugin-polyfill-node": "^0.8.0",
"rollup-plugin-postcss": "^4.0.0", "rollup-plugin-postcss": "^4.0.0",
"rollup-plugin-svelte": "^7.1.0", "rollup-plugin-svelte": "^7.1.0",
"rollup-plugin-svg": "^2.0.0", "rollup-plugin-svg": "^2.0.0",

View file

@ -142,6 +142,10 @@
// Determine and apply settings to the component // Determine and apply settings to the component
$: applySettings(staticSettings, enrichedSettings, conditionalSettings) $: applySettings(staticSettings, enrichedSettings, conditionalSettings)
// Determine custom css.
// Broken out as a separate variable to minimize reactivity updates.
$: customCSS = cachedSettings?._css
// Scroll the selected element into view // Scroll the selected element into view
$: selected && scrollIntoView() $: selected && scrollIntoView()
@ -151,6 +155,7 @@
children: children.length, children: children.length,
styles: { styles: {
...instance._styles, ...instance._styles,
custom: customCSS,
id, id,
empty: emptyState, empty: emptyState,
interactive, interactive,
@ -249,14 +254,18 @@
// Get raw settings // Get raw settings
let settings = {} let settings = {}
Object.entries(instance) Object.entries(instance)
.filter(([name]) => name === "_conditions" || !name.startsWith("_")) .filter(([name]) => !name.startsWith("_"))
.forEach(([key, value]) => { .forEach(([key, value]) => {
settings[key] = value settings[key] = value
}) })
// Derive static, dynamic and nested settings if the instance changed
let newStaticSettings = { ...settings } let newStaticSettings = { ...settings }
let newDynamicSettings = { ...settings } let newDynamicSettings = { ...settings }
// Attach some internal properties
newDynamicSettings["_conditions"] = instance._conditions
newDynamicSettings["_css"] = instance._styles?.custom
// Derive static, dynamic and nested settings if the instance changed
settingsDefinition?.forEach(setting => { settingsDefinition?.forEach(setting => {
if (setting.nested) { if (setting.nested) {
delete newDynamicSettings[setting.key] delete newDynamicSettings[setting.key]
@ -370,6 +379,11 @@
// setting it on initialSettings directly, we avoid a double render. // setting it on initialSettings directly, we avoid a double render.
cachedSettings[key] = allSettings[key] cachedSettings[key] = allSettings[key]
// Don't update components for internal properties
if (key.startsWith("_")) {
return
}
if (ref?.$$set) { if (ref?.$$set) {
// Programmatically set the prop to avoid svelte reactive statements // Programmatically set the prop to avoid svelte reactive statements
// firing inside components. This circumvents the problems caused by // firing inside components. This circumvents the problems caused by

View file

@ -1,6 +1,5 @@
// import { isFreePlan } from "./utils.js" import { isFreePlan } from "./utils.js"
export const logoEnabled = () => { export const logoEnabled = () => {
return false return isFreePlan()
// return isFreePlan()
} }

View file

@ -17,6 +17,7 @@ import { getAction } from "utils/getAction"
import Provider from "components/context/Provider.svelte" import Provider from "components/context/Provider.svelte"
import { ActionTypes } from "./constants" import { ActionTypes } from "./constants"
import { fetchDatasourceSchema } from "./utils/schema.js" import { fetchDatasourceSchema } from "./utils/schema.js"
import { getAPIKey } from "./utils/api.js"
export default { export default {
API, API,
@ -36,4 +37,5 @@ export default {
fetchDatasourceSchema, fetchDatasourceSchema,
Provider, Provider,
ActionTypes, ActionTypes,
getAPIKey,
} }

View file

@ -0,0 +1,6 @@
import { API } from "api"
export const getAPIKey = async () => {
const { apiKey } = await API.fetchDeveloperInfo()
return apiKey
}

View file

@ -1,12 +1,12 @@
{ {
"name": "@budibase/frontend-core", "name": "@budibase/frontend-core",
"version": "1.4.17", "version": "1.4.18-alpha.0",
"description": "Budibase frontend core libraries used in builder and client", "description": "Budibase frontend core libraries used in builder and client",
"author": "Budibase", "author": "Budibase",
"license": "MPL-2.0", "license": "MPL-2.0",
"svelte": "src/index.js", "svelte": "src/index.js",
"dependencies": { "dependencies": {
"@budibase/bbui": "^1.4.17", "@budibase/bbui": "1.4.18-alpha.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"svelte": "^3.46.2" "svelte": "^3.46.2"
} }

View file

@ -1,40 +1,91 @@
export const buildGroupsEndpoints = API => ({ export const buildGroupsEndpoints = API => {
/** // underlying functionality of adding/removing users/apps to groups
* Creates a user group. async function updateGroupResource(groupId, resource, operation, ids) {
* @param user the new group to create if (!Array.isArray(ids)) {
*/ ids = [ids]
saveGroup: async group => { }
return await API.post({ return await API.post({
url: "/api/global/groups", url: `/api/global/groups/${groupId}/${resource}`,
body: group, body: {
[operation]: ids,
},
}) })
}, }
/**
* Gets all of the user groups
*/
getGroups: async () => {
return await API.get({
url: "/api/global/groups",
})
},
/** return {
* Gets a group by ID /**
*/ * Creates a user group.
getGroup: async id => { * @param group the new group to create
return await API.get({ */
url: `/api/global/groups/${id}`, saveGroup: async group => {
}) return await API.post({
}, url: "/api/global/groups",
body: group,
})
},
/**
* Gets all the user groups
*/
getGroups: async () => {
return await API.get({
url: "/api/global/groups",
})
},
/** /**
* Deletes a user group * Gets a group by ID
* @param id the id of the config to delete */
* @param rev the revision of the config to delete getGroup: async id => {
*/ return await API.get({
deleteGroup: async ({ id, rev }) => { url: `/api/global/groups/${id}`,
return await API.delete({ })
url: `/api/global/groups/${id}/${rev}`, },
})
}, /**
}) * Deletes a user group
* @param id the id of the config to delete
* @param rev the revision of the config to delete
*/
deleteGroup: async ({ id, rev }) => {
return await API.delete({
url: `/api/global/groups/${id}/${rev}`,
})
},
/**
* Adds users to a group
* @param groupId The group to update
* @param userIds The user IDs to be added
*/
addUsersToGroup: async (groupId, userIds) => {
return updateGroupResource(groupId, "users", "add", userIds)
},
/**
* Removes users from a group
* @param groupId The group to update
* @param userIds The user IDs to be removed
*/
removeUsersFromGroup: async (groupId, userIds) => {
return updateGroupResource(groupId, "users", "remove", userIds)
},
/**
* Adds apps to a group
* @param groupId The group to update
* @param appArray Array of objects, containing the appId and roleId to be added
*/
addAppsToGroup: async (groupId, appArray) => {
return updateGroupResource(groupId, "apps", "add", appArray)
},
/**
* Removes apps from a group
* @param groupId The group to update
* @param appArray Array of objects, containing the appId to be removed
*/
removeAppsFromGroup: async (groupId, appArray) => {
return updateGroupResource(groupId, "apps", "remove", appArray)
},
}
}

View file

@ -9,6 +9,15 @@ export const buildLicensingEndpoints = API => ({
}) })
}, },
/**
* Delete a self hosted license key
*/
deleteLicenseKey: async () => {
return API.delete({
url: `/api/global/license/info`,
})
},
/** /**
* Get the license info - metadata about the license including the * Get the license info - metadata about the license including the
* obfuscated license key. * obfuscated license key.

View file

@ -86,15 +86,19 @@ export const buildUserEndpoints = API => ({
/** /**
* Creates multiple users. * Creates multiple users.
* @param users the array of user objects to create * @param users the array of user objects to create
* @param groups the array of group ids to add all users to
*/ */
createUsers: async ({ users, groups }) => { createUsers: async ({ users, groups }) => {
return await API.post({ const res = await API.post({
url: "/api/global/users/bulkCreate", url: "/api/global/users/bulk",
body: { body: {
users, create: {
groups, users,
groups,
},
}, },
}) })
return res.created
}, },
/** /**
@ -109,15 +113,18 @@ export const buildUserEndpoints = API => ({
/** /**
* Deletes multiple users * Deletes multiple users
* @param userId the ID of the user to delete * @param userIds the ID of the user to delete
*/ */
deleteUsers: async userIds => { deleteUsers: async userIds => {
return await API.post({ const res = await API.post({
url: `/api/global/users/bulkDelete`, url: `/api/global/users/bulk`,
body: { body: {
userIds, delete: {
userIds,
},
}, },
}) })
return res.deleted
}, },
/** /**
@ -151,6 +158,7 @@ export const buildUserEndpoints = API => ({
userInfo: { userInfo: {
admin: user.admin ? { global: true } : undefined, admin: user.admin ? { global: true } : undefined,
builder: user.admin || user.builder ? { global: true } : undefined, builder: user.admin || user.builder ? { global: true } : undefined,
groups: user.groups,
}, },
})), })),
}) })

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