1
0
Fork 0
mirror of synced 2024-06-26 18:10:51 +12:00

Merge branch 'develop' of github.com:Budibase/budibase into public-api-sdk

This commit is contained in:
Andrew Kingston 2022-09-26 14:15:25 +01:00
commit 0ebfad7606
191 changed files with 2389 additions and 1914 deletions

View file

@ -23,6 +23,15 @@ jobs:
build:
runs-on: ubuntu-latest
services:
couchdb:
image: ibmcom/couchdb3
env:
COUCHDB_PASSWORD: budibase
COUCHDB_USER: budibase
ports:
- 4567:5984
strategy:
matrix:
node-version: [14.x]
@ -53,13 +62,6 @@ jobs:
name: codecov-umbrella
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
run: |
cd qa-core

View file

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

View file

@ -1,6 +1,6 @@
#!/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 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
[[ -z "${APP_PORT}" ]] && export APP_PORT=4001
[[ -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 "${NODE_ENV}" ]] && export NODE_ENV=production
[[ -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 "${SELF_HOSTED}" ]] && export SELF_HOSTED=1
[[ -z "${WORKER_PORT}" ]] && export WORKER_PORT=4002

View file

@ -1,5 +1,5 @@
{
"version": "1.4.3-alpha.2",
"version": "1.4.8-alpha.10",
"npmClient": "yarn",
"packages": [
"packages/*"

View file

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

View file

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

View file

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

View file

@ -40,9 +40,9 @@ export async function usersAdded(count: number, group: UserGroup) {
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 = {
count: emails.length,
count,
groupId: group._id as string,
}
await publishEvent(Event.USER_GROUP_USERS_REMOVED, properties)

View file

@ -1,27 +1,78 @@
import { publishEvent } from "../events"
import {
Event,
License,
LicenseActivatedEvent,
LicenseDowngradedEvent,
LicenseUpdatedEvent,
LicenseUpgradedEvent,
LicensePlanChangedEvent,
LicenseTierChangedEvent,
PlanType,
Account,
LicensePortalOpenedEvent,
LicenseCheckoutSuccessEvent,
LicenseCheckoutOpenedEvent,
LicensePaymentFailedEvent,
LicensePaymentRecoveredEvent,
} from "@budibase/types"
// TODO
export async function updgraded(license: License) {
const properties: LicenseUpgradedEvent = {}
await publishEvent(Event.LICENSE_UPGRADED, properties)
export async function tierChanged(account: Account, from: number, to: number) {
const properties: LicenseTierChangedEvent = {
accountId: account.accountId,
to,
from,
}
await publishEvent(Event.LICENSE_TIER_CHANGED, properties)
}
// TODO
export async function downgraded(license: License) {
const properties: LicenseDowngradedEvent = {}
await publishEvent(Event.LICENSE_DOWNGRADED, properties)
export async function planChanged(
account: Account,
from: PlanType,
to: PlanType
) {
const properties: LicensePlanChangedEvent = {
accountId: account.accountId,
to,
from,
}
await publishEvent(Event.LICENSE_PLAN_CHANGED, properties)
}
// TODO
export async function activated(license: License) {
const properties: LicenseActivatedEvent = {}
export async function activated(account: Account) {
const properties: LicenseActivatedEvent = {
accountId: account.accountId,
}
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
}
exports.FeatureFlag = {
exports.TenantFeatureFlag = {
LICENSING: "LICENSING",
GOOGLE_SHEETS: "GOOGLE_SHEETS",
USER_GROUPS: "USER_GROUPS",

View file

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

View file

@ -78,7 +78,7 @@ function isBuiltin(role) {
*/
exports.builtinRoleToNumber = id => {
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) {
return MAX
}
@ -94,6 +94,22 @@ exports.builtinRoleToNumber = id => {
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.
*/
@ -172,7 +188,7 @@ async function getAllUserRoles(userRoleId) {
* 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 {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.
*/
exports.getUserRoleHierarchy = async (userRoleId, opts = { idOnly: true }) => {

View file

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

View file

@ -6,7 +6,24 @@ import {
} from "./db/utils"
import { queryGlobalView } from "./db/views"
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

View file

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

View file

@ -9,13 +9,13 @@
import StatusLight from "../../StatusLight/StatusLight.svelte"
import Detail from "../../Typography/Detail.svelte"
import Search from "./Search.svelte"
import IconAvatar from "../../Icon/IconAvatar.svelte"
export let primaryLabel = ""
export let primaryValue = null
export let id = null
export let placeholder = "Choose an option or type"
export let disabled = false
export let updateOnChange = true
export let error = null
export let secondaryOptions = []
export let primaryOptions = []
@ -204,19 +204,11 @@
})}
>
{#if primaryOptions[title].getIcon(option)}
<div
style="background: {primaryOptions[title].getColour(
option
)};"
class="circle"
>
<div>
<Icon
size="S"
name={primaryOptions[title].getIcon(option)}
/>
</div>
</div>
<IconAvatar
size="S"
icon={primaryOptions[title].getIcon(option)}
background={primaryOptions[title].getColour(option)}
/>
{:else if getPrimaryOptionColour(option, idx)}
<span class="option-left">
<StatusLight
@ -226,12 +218,13 @@
</span>
{/if}
<span class="spectrum-Menu-itemLabel">
<span
<div
class="primary-text"
class:spacing-group={primaryOptions[title].getIcon(option)}
>
{primaryOptions[title].getLabel(option)}
<span />
</span>
</div>
<svg
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon"
focusable="false"
@ -335,6 +328,11 @@
</div>
<style>
.primary-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.spacing-group {
margin-left: var(--spacing-m);
}
@ -367,25 +365,6 @@
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 {
position: absolute;
top: 50%;

View file

@ -14,7 +14,6 @@
export let disabled = false
export let readonly = false
export let error = null
export let updateOnChange = true
export let getSecondaryOptionLabel = option =>
extractProperty(option, "label")
export let getSecondaryOptionValue = option =>
@ -100,7 +99,6 @@
{searchTerm}
{autocomplete}
{dataCy}
{updateOnChange}
{error}
{disabled}
{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>
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 Avatar from "../Avatar/Avatar.svelte"
export let icon = null
export let iconBackground = null
export let iconColor = null
export let avatar = false
export let title = null
export let subtitle = null
@ -17,9 +18,7 @@
<div class="list-item" class:hoverable on:click>
<div class="left">
{#if icon}
<div class="icon" style="background: {iconBackground || `transparent`};">
<Icon name={icon} size="S" color={iconBackground ? "white" : null} />
</div>
<IconAvatar {icon} color={iconColor} background={iconBackground} />
{/if}
{#if avatar}
<Avatar {initials} />
@ -88,11 +87,4 @@
overflow: hidden;
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>

View file

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

View file

@ -65,6 +65,7 @@
<style>
.spectrum-Popover {
min-width: var(--spectrum-global-dimension-size-2000);
border-color: var(--spectrum-global-color-gray-300);
}
.spectrum-Popover.is-open.spectrum-Popover--withTip {
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 ClearButton } from "./ClearButton/ClearButton.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 RadioGroup } from "./Form/RadioGroup.svelte"
export { default as Checkbox } from "./Form/Checkbox.svelte"

View file

@ -1,16 +1,14 @@
const cypressConfig = require("../cypress.json")
const path = require("path")
const tmpdir = path.join(require("os").tmpdir(), ".budibase")
// normal development system
const SERVER_PORT = cypressConfig.env.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.JWT_SECRET = cypressConfig.env.JWT_SECRET
process.env.COUCH_URL = `leveldb://${tmpdir}/.data/`
process.env.SELF_HOSTED = 1
process.env.WORKER_URL = `http://localhost:${WORKER_PORT}/`
process.env.APPS_URL = `http://localhost:${SERVER_PORT}/`

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,13 +1,23 @@
<script>
import { Select } from "@budibase/bbui"
import { roles } from "stores/backend"
import { RoleUtils } from "@budibase/frontend-core"
import { Constants, RoleUtils } from "@budibase/frontend-core"
export let value
export let error
export let placeholder = null
export let autoWidth = 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>
<Select
@ -15,7 +25,7 @@
{quiet}
bind:value
on:change
options={$roles}
{options}
getOptionLabel={role => role.name}
getOptionValue={role => role._id}
getOptionColour={role => RoleUtils.getRoleColour(role._id)}

View file

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

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

View file

@ -113,7 +113,7 @@ const buildPaymentFailedBanner = () => {
key: "payment_Failed",
type: BANNER_TYPES.NEGATIVE,
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
${get(licensing)?.pastDueDaysRemaining} day${

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,30 +1,41 @@
<script>
import {
TextArea,
DetailSummary,
ActionButton,
Drawer,
DrawerContent,
Layout,
Body,
Button,
notifications,
} 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
let tempValue
let drawer
$: bindings = getBindableProperties(
$selectedScreen,
$store.selectedComponentId
)
const openDrawer = () => {
tempValue = componentInstance?._styles?.custom
tempValue = runtimeToReadableBinding(
bindings,
componentInstance?._styles?.custom
)
drawer.show()
}
const save = async () => {
try {
await store.actions.components.updateCustomStyle(tempValue)
const value = readableToRuntimeBinding(bindings, tempValue)
await store.actions.components.updateCustomStyle(value)
} catch (error) {
notifications.error("Error updating custom style")
}
@ -42,26 +53,17 @@
</DetailSummary>
{#key componentInstance?._id}
<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>
<DrawerContent slot="body">
<div class="content">
<Layout gap="S" noPadding>
<Body size="S">Custom CSS overrides all other component styles.</Body>
<TextArea bind:value={tempValue} placeholder="Enter some CSS..." />
</Layout>
</div>
</DrawerContent>
<svelte:component
this={ClientBindingPanel}
slot="body"
value={tempValue}
on:change={event => (tempValue = event.detail)}
allowJS
{bindings}
/>
</Drawer>
{/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
if (!screenAccessRole) {
console.log("NO ROLE")
return
}
screen.routing.roleId = screenAccessRole

View file

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

View file

@ -19,7 +19,7 @@
import ChangePasswordModal from "components/settings/ChangePasswordModal.svelte"
import UpdateAPIKeyModal from "components/settings/UpdateAPIKeyModal.svelte"
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 userInfoModal
@ -44,7 +44,7 @@
href: "/builder/portal/manage/users",
heading: "Manage",
},
isEnabled(FEATURE_FLAGS.USER_GROUPS)
isEnabled(TENANT_FEATURE_FLAGS.USER_GROUPS)
? {
title: "User 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
menu = menu.concat([
{

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,13 +5,16 @@
Button,
Layout,
Heading,
Body,
Icon,
Popover,
notifications,
List,
ListItem,
StatusLight,
Divider,
ActionMenu,
MenuItem,
Modal,
} from "@budibase/bbui"
import UserGroupPicker from "components/settings/UserGroupPicker.svelte"
import { createPaginationStore } from "helpers/pagination"
@ -19,91 +22,32 @@
import { onMount } from "svelte"
import { RoleUtils } from "@budibase/frontend-core"
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
let popoverAnchor
let popover
let searchTerm = ""
let selectedUsers = []
let prevSearch = undefined
let pageInfo = createPaginationStore()
let loaded = false
let editModal
let deleteModal
$: page = $pageInfo.page
$: fetchUsers(page, searchTerm)
$: group = $groups.find(x => x._id === groupId)
async function addAll() {
selectedUsers = [...selectedUsers, ...filtered.map(u => u._id)]
let reducedUserObjects = filtered.map(u => {
return {
_id: u._id,
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)
$: filtered = $users.data
$: groupApps = $apps.filter(app =>
groups.actions.getGroupAppIds(group).includes(`app_${app.appId}`)
)
$: {
if (loaded && !group?._id) {
$goto("./")
}
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) {
@ -131,6 +75,24 @@
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 () => {
try {
await Promise.all([groups.actions.init(), apps.load(), roles.fetch()])
@ -142,119 +104,137 @@
</script>
{#if loaded}
<Layout noPadding>
<Layout noPadding gap="XL">
<div>
<ActionButton
on:click={() => $goto("../groups")}
size="S"
icon="ArrowLeft"
>
<ActionButton on:click={() => $goto("../groups")} icon="ArrowLeft">
Back
</ActionButton>
</div>
<div class="header">
<div class="title">
<div style="background: {group?.color};" class="circle">
<div>
<Icon size="M" name={group?.icon} />
<Layout noPadding gap="M">
<div class="header">
<div class="title">
<GroupIcon {group} size="L" />
<div class="text-padding">
<Heading>{group?.name}</Heading>
</div>
</div>
<div class="text-padding">
<Heading>{group?.name}</Heading>
<div>
<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 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>
{#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>
<Divider />
<List>
{#if groupApps.length}
{#each groupApps as app}
<ListItem
title={app.name}
icon={app?.icon?.name || "Apps"}
iconBackground={app?.icon?.color || ""}
>
<div class="title ">
<StatusLight
square
color={RoleUtils.getRoleColour(group.roles[`app_${app.appId}`])}
<Layout noPadding gap="S">
<div class="header">
<Heading size="S">Users</Heading>
<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
bind:searchTerm
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)}
</StatusLight>
</div>
</ListItem>
{/each}
{:else}
<ListItem icon="UserGroup" title="No apps" />
{/if}
</List>
<Icon
on:click={e => {
groups.actions.removeUser(groupId, user._id)
e.stopPropagation()
}}
hoverable
size="S"
name="Close"
/>
</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[`app_${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>
{/if}
<style>
.text-padding {
margin-left: var(--spacing-l);
}
<Modal bind:this={editModal}>
<CreateEditGroupModal {group} {saveGroup} />
</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 {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.title {
display: flex;
}
.circle {
border-radius: 50%;
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);
justify-content: flex-start;
align-items: center;
gap: var(--spacing-m);
}
</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>
import { Avatar } from "@budibase/bbui"
import GroupIcon from "./GroupIcon.svelte"
export let value
export let row
</script>
<div class="align">
{#if value}
<div class="spacing">
<Avatar
size="L"
initials={value
.split(" ")
.map(x => x[0])
.join("")}
/>
</div>
<GroupIcon group={row} />
{value}
{:else}
<div class="text">-</div>
@ -26,12 +19,8 @@
display: flex;
align-items: center;
overflow: hidden;
gap: var(--spacing-m);
}
.spacing {
margin-right: var(--spacing-m);
}
.text {
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,
Body,
Button,
ButtonGroup,
Modal,
Tag,
Tags,
Table,
Divider,
Search,
notifications,
} from "@budibase/bbui"
import { groups, auth } from "stores/portal"
import { groups, auth, licensing, admin } from "stores/portal"
import { onMount } from "svelte"
import CreateEditGroupModal from "./_components/CreateEditGroupModal.svelte"
import UserGroupsRow from "./_components/UserGroupsRow.svelte"
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 = {
name: "",
@ -23,20 +30,38 @@
apps: [],
roles: {},
}
let modal
let group = cloneDeep(DefaultGroup)
async function deleteGroup(group) {
try {
groups.actions.delete(group)
} catch (error) {
notifications.error(`Failed to delete group`)
let modal
let searchString
let group = cloneDeep(DefaultGroup)
let customRenderers = [
{ 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) {
try {
await groups.actions.save(group)
group = await groups.actions.save(group)
$goto(`./${group._id}`)
notifications.success(`User group created successfully`)
} catch (error) {
if (error.status === 400) {
notifications.error(error.message)
@ -53,62 +78,81 @@
onMount(async () => {
try {
if ($auth.groupsEnabled) {
// always load latest
await licensing.init()
if ($licensing.groupsEnabled) {
await groups.actions.init()
}
} catch (error) {
notifications.error("Error getting User groups")
notifications.error("Error getting user groups")
}
})
</script>
<Layout noPadding>
<Layout noPadding gap="M">
<Layout gap="XS" noPadding>
<div style="display: flex;">
<Heading size="M">User groups</Heading>
{#if !$auth.groupsEnabled}
<Tags>
<div class="tags">
<div class="tag">
<Tag icon="LockClosed">Pro plan</Tag>
</div>
<Heading size="M">User groups</Heading>
{#if !$licensing.groupsEnabled}
<Tags>
<div class="tags">
<div class="tag">
<Tag icon="LockClosed">Pro plan</Tag>
</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>
{/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>
{/if}
</div>
<Table
on:click={({ detail }) => $goto(`./${detail._id}`)}
{schema}
data={filteredGroups}
allowEditColumns={false}
allowEditRows={false}
{customRenderers}
/>
</Layout>
<Modal bind:this={modal}>
@ -116,37 +160,24 @@
</Modal>
<style>
.align-buttons {
.controls {
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 {
margin-top: var(--spacing-xs);
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>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,37 +5,47 @@
ActionButton,
Layout,
Icon,
notifications,
} from "@budibase/bbui"
import { roles } from "stores/backend"
import { groups, users, auth } from "stores/portal"
import { RoleUtils } from "@budibase/frontend-core"
import { createPaginationStore } from "helpers/pagination"
import { groups, users, licensing, apps } from "stores/portal"
import { Constants, RoleUtils, fetchData } from "@budibase/frontend-core"
import { API } from "api"
import { createEventDispatcher } from "svelte"
export let app
export let addData
export let appUsers = []
let prevSearch = undefined,
search = undefined
let pageInfo = createPaginationStore()
let appData = [{ id: "", role: "" }]
$: page = $pageInfo.page
$: fetchUsers(page, search)
$: availableUsers = getAvailableUsers($users, appUsers, appData)
$: filteredGroups = $groups.filter(group => {
return !group.apps.find(appId => {
return appId === app.appId
})
const dispatch = createEventDispatcher()
const usersFetch = fetchData({
API,
datasource: {
type: "user",
},
options: {
query: {
email: "",
},
},
})
$: 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)
$: console.log(availableGroups)
$: valid = data?.length && !data?.some(x => !x.id?.length || !x.role?.length)
$: optionSections = {
...($auth.groupsEnabled &&
filteredGroups.length && {
...($licensing.groupsEnabled &&
availableGroups.length && {
["User groups"]: {
data: filteredGroups,
data: availableGroups,
getLabel: group => group.name,
getValue: group => group._id,
getIcon: group => group.icon,
@ -51,8 +61,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) => {
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
if (appUsers.find(x => x._id === user._id)) {
return false
@ -63,31 +110,31 @@
})
}
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, email: search })
pageInfo.fetched($users.hasNextPage, $users.nextPage)
} catch (error) {
notifications.error("Error getting user list")
}
const getAvailableGroups = (allGroups, appId, search, newGroups) => {
search = search?.toLowerCase()
return (allGroups || []).filter(group => {
// Filter out assigned groups
const appIds = groups.actions.getGroupAppIds(group)
if (appIds.includes(`app_${appId}`)) {
return false
}
// Filter out new groups which are going to be assigned
if (newGroups.find(x => x.id === group._id)) {
return false
}
// Match search string
return !search || group.name.toLowerCase().includes(search)
})
}
function addNewInput() {
appData = [...appData, { id: "", role: "" }]
data = [...data, { id: "", role: "" }]
}
const removeItem = index => {
appData = appData.filter((x, idx) => idx !== index)
data = data.filter((x, idx) => idx !== index)
}
</script>
@ -96,20 +143,22 @@
title="Assign users to your app"
confirmText="Done"
cancelText="Cancel"
onConfirm={() => addData(appData)}
onConfirm={() => addData(data)}
showCloseIcon={false}
disabled={!valid}
>
{#if appData?.length}
{#if data.length}
<Layout noPadding gap="XS">
{#each appData as input, index}
{#each data as input, index}
<div class="item">
<div class="picker">
<PickerDropdown
autocomplete
showClearIcon={false}
primaryOptions={optionSections}
secondaryOptions={$roles}
secondaryOptions={$roles.filter(
x => x._id !== Constants.Roles.PUBLIC
)}
secondaryPlaceholder="Access"
bind:primaryValue={input.id}
bind:secondaryValue={input.role}

View file

@ -5,30 +5,56 @@
import { store } from "builderStore"
import clientPackage from "@budibase/client/package.json"
import { processStringSync } from "@budibase/string-templates"
import { users, auth, apps } from "stores/portal"
import { createEventDispatcher, onMount } from "svelte"
import { users, auth, apps, groups } from "stores/portal"
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 deployments
export let navigateTab
let userCount
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 = () => {
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) {
appEditorPromise = users.get(editorId)
appEditor = await appEditorPromise
appEditor = await users.get(editorId)
}
const getInitials = user => {
@ -36,16 +62,8 @@
initials += user.firstName ? user.firstName[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>
<div class="overview-tab">
@ -83,11 +101,9 @@
</div>
</div>
</DashCard>
<DashCard title={"Last Edited"} dataCy={"edited-by"}>
<div class="last-edited-content">
{#await appEditorPromise}
<Avatar size="M" initials={"-"} />
{:then _}
{#if appEditor}
<DashCard title={"Last Edited"} dataCy={"edited-by"}>
<div class="last-edited-content">
<div class="updated-by">
{#if appEditor}
<Avatar size="M" initials={getInitials(appEditor)} />
@ -96,22 +112,20 @@
</div>
{/if}
</div>
{:catch error}
<p>Could not fetch user: {error.message}</p>
{/await}
<div class="last-edit-text">
{#if app}
{processStringSync(
"Last edited {{ duration time 'millisecond' }} ago",
{
time:
new Date().getTime() - new Date(app?.updatedAt).getTime(),
}
)}
{/if}
<div class="last-edit-text">
{#if app}
{processStringSync(
"Last edited {{ duration time 'millisecond' }} ago",
{
time:
new Date().getTime() - new Date(app?.updatedAt).getTime(),
}
)}
{/if}
</div>
</div>
</div>
</DashCard>
</DashCard>
{/if}
<DashCard
title={"App Version"}
showIcon={true}
@ -141,26 +155,44 @@
{/if}
</div>
</DashCard>
<DashCard
title={"Access"}
showIcon={true}
action={() => {
navigateTab("Access")
}}
dataCy={"access"}
>
<div class="last-edited-content">
{#if $users?.data?.length}
{#if $appUsersFetch.loaded}
<DashCard
title={"Access"}
showIcon={true}
action={() => {
navigateTab("Access")
}}
dataCy={"access"}
>
{#if appUsers.length || appGroups.length}
<Layout noPadding gap="S">
<div class="users-tab">
{#each $users?.data as user}
<Avatar size="M" initials={getInitials(user)} />
{/each}
</div>
<div class="users-text">
{userCount}
{userCount > 1 ? `users have` : `user has`} access to this app
<div class="access-tab-content">
{#if appUsers.length}
<div class="users">
<div class="list">
{#each appUsers.slice(0, 4) as user}
<Avatar size="M" initials={getInitials(user)} />
{/each}
</div>
<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>
</Layout>
{:else}
@ -171,8 +203,8 @@
</div>
</Layout>
{/if}
</div>
</DashCard>
</DashCard>
{/if}
</div>
{#if false}
<div class="bottom">
@ -224,17 +256,29 @@
.overview-tab .top {
display: grid;
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;
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,
.automation-metrics {
display: grid;
@ -242,23 +286,6 @@
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 {
display: flex;
align-items: center;

View file

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

View file

@ -83,7 +83,7 @@
analytics.
</Body>
</Layout>
<Divider size="S" />
<Divider />
<Layout gap="XS" noPadding>
<Heading size="S">Information</Heading>
<Body size="S">Here you can update your logo and organization name.</Body>
@ -110,7 +110,7 @@
</div>
</div>
{#if !$admin.cloud}
<Divider size="S" />
<Divider />
<Layout gap="XS" noPadding>
<Heading size="S">Platform</Heading>
<Body size="S">Here you can set up general platform settings.</Body>
@ -128,7 +128,7 @@
</div>
{/if}
{#if !$admin.cloud}
<Divider size="S" />
<Divider />
<Layout gap="XS" noPadding>
<Heading size="S">Analytics</Heading>
<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>
<Body>Customize how Budibase looks and feels.</Body>
</Layout>
<Divider size="S" />
<Divider />
<div class="fields">
<div class="field">
<Label size="L">Builder theme</Label>

View file

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

View file

@ -13,6 +13,7 @@
import { auth, admin } from "stores/portal"
import { redirect } from "@roxi/routify"
import { processStringSync } from "@budibase/string-templates"
import DeleteLicenseKeyModal from "../../../../components/portal/licensing/DeleteLicenseKeyModal.svelte"
import { API } from "api"
import { onMount } from "svelte"
@ -26,6 +27,7 @@
let licenseKeyDisabled = false
let licenseKeyType = "text"
let licenseKey = ""
let deleteLicenseKeyModal
// 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 () => {
try {
await API.refreshLicense()
@ -76,23 +92,25 @@
</script>
{#if $auth.isAdmin}
<DeleteLicenseKeyModal
bind:this={deleteLicenseKeyModal}
onConfirm={destroy}
/>
<Layout noPadding>
<Layout gap="XS" noPadding>
<Heading size="M">Upgrade</Heading>
<Body size="M">
{#if license.plan.type === "free"}
Upgrade your budibase installation to unlock additional features. To
subscribe to a plan visit your <Link size="L" href={upgradeUrl}
>Account</Link
>.
Upgrade your Budibase installation to unlock additional features. To
subscribe to a plan visit your
<Link size="L" href={upgradeUrl}>Account</Link>.
{:else}
To manage your plan visit your <Link size="L" href={upgradeUrl}
>Account</Link
>.
To manage your plan visit your
<Link size="L" href={upgradeUrl}>Account</Link>.
{/if}
</Body>
</Layout>
<Divider size="S" />
<Divider />
<Layout gap="XS" noPadding>
<Heading size="S">Activate</Heading>
<Body size="S">Enter your license key below to activate your plan</Body>
@ -100,7 +118,7 @@
<Layout noPadding>
<div class="fields">
<div class="field">
<Label size="L">License Key</Label>
<Label size="L">License key</Label>
<Input
thin
bind:value={licenseKey}
@ -109,13 +127,22 @@
/>
</div>
</div>
<div>
<Button cta on:click={activate} disabled={activateDisabled}
>Activate</Button
>
<div class="button-container">
<div class="action-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>
</Layout>
<Divider size="S" />
<Divider />
<Layout gap="L" noPadding>
<Layout gap="S" noPadding>
<Heading size="S">Plan</Heading>
@ -152,4 +179,10 @@
grid-gap: var(--spacing-l);
align-items: center;
}
.action-button {
margin-right: 10px;
}
.button-container {
display: flex;
}
</style>

View file

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

View file

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

View file

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

View file

@ -1,36 +1,45 @@
import { writable, get } from "svelte/store"
import { API } from "api"
import { auth } from "stores/portal"
import { Constants } from "@budibase/frontend-core"
import { licensing } from "stores/portal"
export function createGroupsStore() {
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 = {
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
if (
get(auth).user.license.features.includes(Constants.Features.USER_GROUPS)
) {
const users = await API.getGroups()
store.set(users)
if (get(licensing).groupsEnabled) {
const groups = await API.getGroups()
store.set(groups)
}
},
get: getGroup,
save: async group => {
const response = await API.saveGroup(group)
group._id = response._id
group._rev = response._rev
store.update(state => {
const currentIdx = state.findIndex(gr => gr._id === response._id)
if (currentIdx >= 0) {
state.splice(currentIdx, 1, group)
} else {
state.push(group)
}
return state
})
updateStore(group)
return group
},
delete: async group => {
@ -43,6 +52,34 @@ export function createGroupsStore() {
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 {

View file

@ -1,14 +1,31 @@
import { writable, get } from "svelte/store"
import { API } from "api"
import { auth } from "stores/portal"
import { auth, admin } from "stores/portal"
import { Constants } from "@budibase/frontend-core"
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 = () => {
const DEFAULT = {
plans: {},
usageMetrics: {},
// navigation
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
@ -16,10 +33,39 @@ export const createLicensingStore = () => {
const actions = {
init: async () => {
await actions.getQuotaUsage()
await actions.getUsageMetrics()
actions.setNavigation()
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()
store.update(state => {
return {
@ -28,8 +74,8 @@ export const createLicensingStore = () => {
}
})
},
getUsageMetrics: async () => {
if (isEnabled(FEATURE_FLAGS.LICENSING)) {
setUsageMetrics: () => {
if (isEnabled(TENANT_FEATURE_FLAGS.LICENSING)) {
const quota = get(store).quotaUsage
const license = get(auth).user.license
const now = new Date()
@ -97,9 +143,6 @@ export const createLicensingStore = () => {
accountPastDue: pastDueAtMilliseconds != null,
pastDueEndDate,
pastDueDaysRemaining,
isFreePlan: () => {
return license?.plan.type === Constants.PlanType.FREE
},
}
})
}

View file

@ -34,8 +34,7 @@ export function createPluginsStore() {
}
let res = await API.createPlugin(pluginData)
let newPlugin = res.plugins[0]
let newPlugin = res.plugin
update(state => {
const currentIdx = state.findIndex(plugin => plugin._id === newPlugin._id)
if (currentIdx >= 0) {

View file

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

View file

@ -1,6 +1,6 @@
{
"name": "@budibase/client",
"version": "1.4.3-alpha.2",
"version": "1.4.8-alpha.10",
"license": "MPL-2.0",
"module": "dist/budibase-client.js",
"main": "dist/budibase-client.js",
@ -19,9 +19,9 @@
"dev:builder": "rollup -cw"
},
"dependencies": {
"@budibase/bbui": "1.4.3-alpha.2",
"@budibase/frontend-core": "1.4.3-alpha.2",
"@budibase/string-templates": "1.4.3-alpha.2",
"@budibase/bbui": "1.4.8-alpha.10",
"@budibase/frontend-core": "1.4.8-alpha.10",
"@budibase/string-templates": "1.4.8-alpha.10",
"@spectrum-css/button": "^3.0.3",
"@spectrum-css/card": "^3.0.3",
"@spectrum-css/divider": "^1.0.3",

View file

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

View file

@ -374,6 +374,11 @@
min-height: 180px;
min-width: 200px;
}
.embedded-map :global(a.map-svg-button) {
display: flex;
justify-content: center;
align-items: center;
}
.embedded-map :global(.leaflet-top),
.embedded-map :global(.leaflet-bottom) {
z-index: 998;

View file

@ -37,7 +37,7 @@ const FullScreenControl = L.Control.extend({
this._fullScreenButton = this._createButton(
options.fullScreenContent,
options.fullScreenTitle,
"map-fullscreen",
"map-fullscreen map-svg-button",
container,
this._fullScreen
)
@ -87,7 +87,7 @@ const LocationControl = L.Control.extend({
this._locationButton = this._createButton(
options.locationContent,
options.locationTitle,
"map-location",
"map-location map-svg-button",
container,
this._location
)

View file

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

View file

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

View file

@ -1,40 +1,91 @@
export const buildGroupsEndpoints = API => ({
/**
* Creates a user group.
* @param user the new group to create
*/
saveGroup: async group => {
export const buildGroupsEndpoints = API => {
// underlying functionality of adding/removing users/apps to groups
async function updateGroupResource(groupId, resource, operation, ids) {
if (!Array.isArray(ids)) {
ids = [ids]
}
return await API.post({
url: "/api/global/groups",
body: group,
url: `/api/global/groups/${groupId}/${resource}`,
body: {
[operation]: ids,
},
})
},
/**
* Gets all of the user groups
*/
getGroups: async () => {
return await API.get({
url: "/api/global/groups",
})
},
}
/**
* Gets a group by ID
*/
getGroup: async id => {
return await API.get({
url: `/api/global/groups/${id}`,
})
},
return {
/**
* Creates a user group.
* @param group the new group to create
*/
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
* @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}`,
})
},
})
/**
* Gets a group by ID
*/
getGroup: async id => {
return await API.get({
url: `/api/global/groups/${id}`,
})
},
/**
* 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
* obfuscated license key.

View file

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

View file

@ -158,6 +158,8 @@ export default class DataFetch {
schema,
query,
loading: true,
cursors: [],
cursor: null,
}))
// Actually fetch data

View file

@ -0,0 +1,52 @@
import { get } from "svelte/store"
import DataFetch from "./DataFetch.js"
import { TableNames } from "../constants"
export default class UserFetch extends DataFetch {
constructor(opts) {
super({
...opts,
datasource: {
tableId: TableNames.USERS,
},
})
}
determineFeatureFlags() {
return {
supportsSearch: true,
supportsSort: false,
supportsPagination: true,
}
}
async getDefinition() {
return {
schema: {},
}
}
async getData() {
const { cursor, query } = get(this.store)
try {
// "query" normally contains a lucene query, but users uses a non-standard
// search endpoint so we use query uniquely here
const res = await this.API.searchUsers({
page: cursor,
email: query.email,
appId: query.appId,
})
return {
rows: res?.data || [],
hasNextPage: res?.hasNextPage || false,
cursor: res?.nextPage || null,
}
} catch (error) {
return {
rows: [],
hasNextPage: false,
error,
}
}
}
}

View file

@ -5,12 +5,14 @@ import RelationshipFetch from "./RelationshipFetch.js"
import NestedProviderFetch from "./NestedProviderFetch.js"
import FieldFetch from "./FieldFetch.js"
import JSONArrayFetch from "./JSONArrayFetch.js"
import UserFetch from "./UserFetch.js"
const DataFetchMap = {
table: TableFetch,
view: ViewFetch,
query: QueryFetch,
link: RelationshipFetch,
user: UserFetch,
// Client specific datasource types
provider: NestedProviderFetch,

View file

@ -28,6 +28,7 @@
--spectrum-global-color-static-blue-600: #5680b4;
--spectrum-global-color-static-blue-700: #4e79af;
--spectrum-global-color-static-blue-800: #4a73a6;
--spectrum-global-color-static-blue: var(--spectrum-global-color-blue-600);
--spectrum-global-color-gray-50: #2e3440;
--spectrum-global-color-gray-75: #353b4a;

View file

@ -19,3 +19,24 @@ export const sequential = fn => {
}
}
}
/**
* Utility to debounce an async function and ensure a minimum delay between
* invocations is enforced.
* @param callback an async function to run
* @param minDelay the minimum delay between invocations
* @returns {Promise} a debounced version of the callback
*/
export const debounce = (callback, minDelay = 1000) => {
let timeout
return async (...params) => {
return new Promise(resolve => {
if (timeout) {
clearTimeout(timeout)
}
timeout = setTimeout(async () => {
resolve(await callback(...params))
}, minDelay)
})
}
}

View file

@ -12,6 +12,8 @@ ENV COUCH_DB_URL=https://couchdb.budi.live:5984
ENV BUDIBASE_ENVIRONMENT=PRODUCTION
ENV SERVICE=app-service
ENV POSTHOG_TOKEN=phc_bIjZL7oh2GEUd2vqvTBH8WvrX0fWTFQMs6H5KQxiUxU
ENV TENANT_FEATURE_FLAGS=*:LICENSING,*:USER_GROUPS
ENV ACCOUNT_PORTAL_URL=https://account.budibase.app
# copy files and install dependencies
COPY . ./

View file

@ -1,7 +1,7 @@
{
"name": "@budibase/server",
"email": "hi@budibase.com",
"version": "1.4.3-alpha.2",
"version": "1.4.8-alpha.10",
"description": "Budibase Web Server",
"main": "src/index.ts",
"repository": {
@ -77,11 +77,11 @@
"license": "GPL-3.0",
"dependencies": {
"@apidevtools/swagger-parser": "10.0.3",
"@budibase/backend-core": "1.4.3-alpha.2",
"@budibase/client": "1.4.3-alpha.2",
"@budibase/pro": "1.4.3-alpha.2",
"@budibase/string-templates": "1.4.3-alpha.2",
"@budibase/types": "1.4.3-alpha.2",
"@budibase/backend-core": "1.4.8-alpha.10",
"@budibase/client": "1.4.8-alpha.10",
"@budibase/pro": "1.4.8-alpha.10",
"@budibase/string-templates": "1.4.8-alpha.10",
"@budibase/types": "1.4.8-alpha.10",
"@bull-board/api": "3.7.0",
"@bull-board/koa": "3.9.4",
"@elastic/elasticsearch": "7.10.0",

View file

@ -59,6 +59,7 @@ async function init() {
BB_ADMIN_USER_EMAIL: "",
BB_ADMIN_USER_PASSWORD: "",
PLUGINS_DIR: "",
TENANT_FEATURE_FLAGS: "*:LICENSING,*:USER_GROUPS",
}
let envFile = ""
Object.keys(envFileJson).forEach(key => {

View file

@ -47,14 +47,9 @@ import { checkAppMetadata } from "../../automations/logging"
import { getUniqueRows } from "../../utilities/usageQuota/rows"
import { quotas } from "@budibase/pro"
import { errors, events, migrations } from "@budibase/backend-core"
import {
App,
Layout,
Screen,
MigrationType,
AppNavigation,
} from "@budibase/types"
import { App, Layout, Screen, MigrationType } from "@budibase/types"
import { BASE_LAYOUT_PROP_IDS } from "../../constants/layouts"
import { groups } from "@budibase/pro"
const URL_REGEX_SLASH = /\/|\\/g
@ -501,6 +496,7 @@ const preDestroyApp = async (ctx: any) => {
const postDestroyApp = async (ctx: any) => {
const rowCount = ctx.rowCount
await groups.cleanupApp(ctx.params.appId)
if (rowCount) {
await quotas.removeRows(rowCount)
}

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