Merge pull request #6571 from Budibase/feat/user-groups-tab
User Groups and User Management UI redesign
This commit is contained in:
commit
998f30642d
|
@ -18,6 +18,8 @@ const {
|
|||
ssoCallbackUrl,
|
||||
csrf,
|
||||
internalApi,
|
||||
adminOnly,
|
||||
joiValidator,
|
||||
} = require("./middleware")
|
||||
|
||||
const { invalidateUser } = require("./cache/user")
|
||||
|
@ -173,4 +175,6 @@ module.exports = {
|
|||
refreshOAuthToken,
|
||||
updateUserOAuth,
|
||||
ssoCallbackUrl,
|
||||
adminOnly,
|
||||
joiValidator,
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ export enum AutomationViewModes {
|
|||
}
|
||||
|
||||
export enum ViewNames {
|
||||
USER_BY_APP = "by_app",
|
||||
USER_BY_EMAIL = "by_email2",
|
||||
BY_API_KEY = "by_api_key",
|
||||
USER_BY_BUILDERS = "by_builders",
|
||||
|
@ -28,6 +29,7 @@ export const DeprecatedViews = {
|
|||
|
||||
export enum DocumentTypes {
|
||||
USER = "us",
|
||||
GROUP = "gr",
|
||||
WORKSPACE = "workspace",
|
||||
CONFIG = "config",
|
||||
TEMPLATE = "template",
|
||||
|
|
|
@ -50,3 +50,8 @@ exports.getProdAppID = appId => {
|
|||
const rest = split.join(APP_DEV_PREFIX)
|
||||
return `${APP_PREFIX}${rest}`
|
||||
}
|
||||
|
||||
exports.extractAppUUID = id => {
|
||||
const split = id?.split("_") || []
|
||||
return split.length ? split[split.length - 1] : null
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ import { doWithDB, allDbs } from "./index"
|
|||
import { getCouchInfo } from "./pouch"
|
||||
import { getAppMetadata } from "../cache/appMetadata"
|
||||
import { checkSlashesInUrl } from "../helpers"
|
||||
import { isDevApp, isDevAppID } from "./conversions"
|
||||
import { isDevApp, isDevAppID, getProdAppID } from "./conversions"
|
||||
import { APP_PREFIX } from "./constants"
|
||||
import * as events from "../events"
|
||||
|
||||
|
@ -107,6 +107,15 @@ export function getGlobalUserParams(globalId: any, otherProps: any = {}) {
|
|||
}
|
||||
}
|
||||
|
||||
export function getUsersByAppParams(appId: any, otherProps: any = {}) {
|
||||
const prodAppId = getProdAppID(appId)
|
||||
return {
|
||||
...otherProps,
|
||||
startkey: prodAppId,
|
||||
endkey: `${prodAppId}${UNICODE_MAX}`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a template ID.
|
||||
* @param ownerId The owner/user of the template, this could be global or a workspace level.
|
||||
|
@ -115,6 +124,10 @@ export function generateTemplateID(ownerId: any) {
|
|||
return `${DocumentTypes.TEMPLATE}${SEPARATOR}${ownerId}${SEPARATOR}${newid()}`
|
||||
}
|
||||
|
||||
export function generateAppUserID(prodAppId: string, userId: string) {
|
||||
return `${prodAppId}${SEPARATOR}${userId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets parameters for retrieving templates. Owner ID must be specified, either global or a workspace level.
|
||||
*/
|
||||
|
@ -442,15 +455,29 @@ export const getPlatformUrl = async (opts = { tenantAware: true }) => {
|
|||
export function pagination(
|
||||
data: any[],
|
||||
pageSize: number,
|
||||
{ paginate, property } = { paginate: true, property: "_id" }
|
||||
{
|
||||
paginate,
|
||||
property,
|
||||
getKey,
|
||||
}: {
|
||||
paginate: boolean
|
||||
property: string
|
||||
getKey?: (doc: any) => string | undefined
|
||||
} = {
|
||||
paginate: true,
|
||||
property: "_id",
|
||||
}
|
||||
) {
|
||||
if (!paginate) {
|
||||
return { data, hasNextPage: false }
|
||||
}
|
||||
const hasNextPage = data.length > pageSize
|
||||
let nextPage = undefined
|
||||
if (!getKey) {
|
||||
getKey = (doc: any) => (property ? doc?.[property] : doc?._id)
|
||||
}
|
||||
if (hasNextPage) {
|
||||
nextPage = property ? data[pageSize]?.[property] : data[pageSize]?._id
|
||||
nextPage = getKey(data[pageSize])
|
||||
}
|
||||
return {
|
||||
data: data.slice(0, pageSize),
|
||||
|
|
|
@ -56,6 +56,33 @@ exports.createNewUserEmailView = async () => {
|
|||
await db.put(designDoc)
|
||||
}
|
||||
|
||||
exports.createUserAppView = async () => {
|
||||
const db = getGlobalDB()
|
||||
let designDoc
|
||||
try {
|
||||
designDoc = await db.get("_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("${DocumentTypes.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,
|
||||
[ViewNames.USER_BY_APP]: view,
|
||||
}
|
||||
await db.put(designDoc)
|
||||
}
|
||||
|
||||
exports.createApiKeyView = async () => {
|
||||
const db = getGlobalDB()
|
||||
let designDoc
|
||||
|
@ -106,6 +133,7 @@ exports.queryGlobalView = async (viewName, params, db = null) => {
|
|||
[ViewNames.USER_BY_EMAIL]: exports.createNewUserEmailView,
|
||||
[ViewNames.BY_API_KEY]: exports.createApiKeyView,
|
||||
[ViewNames.USER_BY_BUILDERS]: exports.createUserBuildersView,
|
||||
[ViewNames.USER_BY_APP]: exports.createUserAppView,
|
||||
}
|
||||
// can pass DB in if working with something specific
|
||||
if (!db) {
|
||||
|
|
|
@ -37,6 +37,7 @@ module.exports = {
|
|||
types,
|
||||
errors: {
|
||||
UsageLimitError: licensing.UsageLimitError,
|
||||
FeatureDisabledError: licensing.FeatureDisabledError,
|
||||
HTTPError: http.HTTPError,
|
||||
},
|
||||
getPublicError,
|
||||
|
|
|
@ -4,6 +4,7 @@ const type = "license_error"
|
|||
|
||||
const codes = {
|
||||
USAGE_LIMIT_EXCEEDED: "usage_limit_exceeded",
|
||||
FEATURE_DISABLED: "feature_disabled",
|
||||
}
|
||||
|
||||
const context = {
|
||||
|
@ -12,6 +13,11 @@ const context = {
|
|||
limitName: err.limitName,
|
||||
}
|
||||
},
|
||||
[codes.FEATURE_DISABLED]: err => {
|
||||
return {
|
||||
featureName: err.featureName,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
class UsageLimitError extends HTTPError {
|
||||
|
@ -21,9 +27,17 @@ class UsageLimitError extends HTTPError {
|
|||
}
|
||||
}
|
||||
|
||||
class FeatureDisabledError extends HTTPError {
|
||||
constructor(message, featureName) {
|
||||
super(message, 400, codes.FEATURE_DISABLED, type)
|
||||
this.featureName = featureName
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
type,
|
||||
codes,
|
||||
context,
|
||||
UsageLimitError,
|
||||
FeatureDisabledError,
|
||||
}
|
||||
|
|
|
@ -0,0 +1,64 @@
|
|||
import { publishEvent } from "../events"
|
||||
import {
|
||||
Event,
|
||||
UserGroup,
|
||||
GroupCreatedEvent,
|
||||
GroupDeletedEvent,
|
||||
GroupUpdatedEvent,
|
||||
GroupUsersAddedEvent,
|
||||
GroupUsersDeletedEvent,
|
||||
GroupAddedOnboardingEvent,
|
||||
UserGroupRoles,
|
||||
} from "@budibase/types"
|
||||
|
||||
export async function created(group: UserGroup, timestamp?: number) {
|
||||
const properties: GroupCreatedEvent = {
|
||||
groupId: group._id as string,
|
||||
}
|
||||
await publishEvent(Event.USER_GROUP_CREATED, properties, timestamp)
|
||||
}
|
||||
|
||||
export async function updated(group: UserGroup) {
|
||||
const properties: GroupUpdatedEvent = {
|
||||
groupId: group._id as string,
|
||||
}
|
||||
await publishEvent(Event.USER_GROUP_UPDATED, properties)
|
||||
}
|
||||
|
||||
export async function deleted(group: UserGroup) {
|
||||
const properties: GroupDeletedEvent = {
|
||||
groupId: group._id as string,
|
||||
}
|
||||
await publishEvent(Event.USER_GROUP_DELETED, properties)
|
||||
}
|
||||
|
||||
export async function usersAdded(count: number, group: UserGroup) {
|
||||
const properties: GroupUsersAddedEvent = {
|
||||
count,
|
||||
groupId: group._id as string,
|
||||
}
|
||||
await publishEvent(Event.USER_GROUP_USERS_ADDED, properties)
|
||||
}
|
||||
|
||||
export async function usersDeleted(emails: string[], group: UserGroup) {
|
||||
const properties: GroupUsersDeletedEvent = {
|
||||
count: emails.length,
|
||||
groupId: group._id as string,
|
||||
}
|
||||
await publishEvent(Event.USER_GROUP_USERS_REMOVED, properties)
|
||||
}
|
||||
|
||||
export async function createdOnboarding(groupId: string) {
|
||||
const properties: GroupAddedOnboardingEvent = {
|
||||
groupId: groupId,
|
||||
onboarding: true,
|
||||
}
|
||||
await publishEvent(Event.USER_GROUP_ONBOARDING, properties)
|
||||
}
|
||||
|
||||
export async function permissionsEdited(roles: UserGroupRoles) {
|
||||
const properties: UserGroupRoles = {
|
||||
...roles,
|
||||
}
|
||||
await publishEvent(Event.USER_GROUP_PERMISSIONS_EDITED, properties)
|
||||
}
|
|
@ -17,3 +17,4 @@ export * as user from "./user"
|
|||
export * as view from "./view"
|
||||
export * as installation from "./installation"
|
||||
export * as backfill from "./backfill"
|
||||
export * as group from "./group"
|
||||
|
|
|
@ -3,6 +3,7 @@ const errorClasses = errors.errors
|
|||
import * as events from "./events"
|
||||
import * as migrations from "./migrations"
|
||||
import * as users from "./users"
|
||||
import * as roles from "./security/roles"
|
||||
import * as accounts from "./cloud/accounts"
|
||||
import * as installation from "./installation"
|
||||
import env from "./environment"
|
||||
|
@ -51,6 +52,7 @@ const core = {
|
|||
installation,
|
||||
errors,
|
||||
logging,
|
||||
roles,
|
||||
...errorClasses,
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
module.exports = async (ctx, next) => {
|
||||
if (
|
||||
!ctx.internal &&
|
||||
(!ctx.user || !ctx.user.admin || !ctx.user.admin.global)
|
||||
) {
|
||||
ctx.throw(403, "Admin user only endpoint.")
|
||||
}
|
||||
return next()
|
||||
}
|
|
@ -9,7 +9,8 @@ const tenancy = require("./tenancy")
|
|||
const internalApi = require("./internalApi")
|
||||
const datasourceGoogle = require("./passport/datasource/google")
|
||||
const csrf = require("./csrf")
|
||||
|
||||
const adminOnly = require("./adminOnly")
|
||||
const joiValidator = require("./joi-validator")
|
||||
module.exports = {
|
||||
google,
|
||||
oidc,
|
||||
|
@ -25,4 +26,6 @@ module.exports = {
|
|||
google: datasourceGoogle,
|
||||
},
|
||||
csrf,
|
||||
adminOnly,
|
||||
joiValidator,
|
||||
}
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
function validate(schema, property) {
|
||||
// Return a Koa middleware function
|
||||
return (ctx, next) => {
|
||||
if (!schema) {
|
||||
return next()
|
||||
}
|
||||
let params = null
|
||||
if (ctx[property] != null) {
|
||||
params = ctx[property]
|
||||
} else if (ctx.request[property] != null) {
|
||||
params = ctx.request[property]
|
||||
}
|
||||
const { error } = schema.validate(params)
|
||||
if (error) {
|
||||
ctx.throw(400, `Invalid ${property} - ${error.message}`)
|
||||
return
|
||||
}
|
||||
return next()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.body = schema => {
|
||||
return validate(schema, "body")
|
||||
}
|
||||
|
||||
module.exports.params = schema => {
|
||||
return validate(schema, "params")
|
||||
}
|
|
@ -76,7 +76,7 @@ function isBuiltin(role) {
|
|||
/**
|
||||
* Works through the inheritance ranks to see how far up the builtin stack this ID is.
|
||||
*/
|
||||
function builtinRoleToNumber(id) {
|
||||
exports.builtinRoleToNumber = id => {
|
||||
const builtins = exports.getBuiltinRoles()
|
||||
const MAX = Object.values(BUILTIN_IDS).length + 1
|
||||
if (id === BUILTIN_IDS.ADMIN || id === BUILTIN_IDS.BUILDER) {
|
||||
|
@ -104,7 +104,8 @@ exports.lowerBuiltinRoleID = (roleId1, roleId2) => {
|
|||
if (!roleId2) {
|
||||
return roleId1
|
||||
}
|
||||
return builtinRoleToNumber(roleId1) > builtinRoleToNumber(roleId2)
|
||||
return exports.builtinRoleToNumber(roleId1) >
|
||||
exports.builtinRoleToNumber(roleId2)
|
||||
? roleId2
|
||||
: roleId1
|
||||
}
|
||||
|
|
|
@ -1,4 +1,9 @@
|
|||
const { ViewNames } = require("./db/utils")
|
||||
const {
|
||||
ViewNames,
|
||||
getUsersByAppParams,
|
||||
getProdAppID,
|
||||
generateAppUserID,
|
||||
} = require("./db/utils")
|
||||
const { queryGlobalView } = require("./db/views")
|
||||
const { UNICODE_MAX } = require("./db/constants")
|
||||
|
||||
|
@ -13,12 +18,32 @@ exports.getGlobalUserByEmail = async email => {
|
|||
throw "Must supply an email address to view"
|
||||
}
|
||||
|
||||
const response = await queryGlobalView(ViewNames.USER_BY_EMAIL, {
|
||||
return await queryGlobalView(ViewNames.USER_BY_EMAIL, {
|
||||
key: email.toLowerCase(),
|
||||
include_docs: true,
|
||||
})
|
||||
}
|
||||
|
||||
return response
|
||||
exports.searchGlobalUsersByApp = async (appId, opts) => {
|
||||
if (typeof appId !== "string") {
|
||||
throw new Error("Must provide a string based app ID")
|
||||
}
|
||||
const params = getUsersByAppParams(appId, {
|
||||
include_docs: true,
|
||||
})
|
||||
params.startkey = opts && opts.startkey ? opts.startkey : params.startkey
|
||||
let response = await queryGlobalView(ViewNames.USER_BY_APP, params)
|
||||
if (!response) {
|
||||
response = []
|
||||
}
|
||||
return Array.isArray(response) ? response : [response]
|
||||
}
|
||||
|
||||
exports.getGlobalUserByAppPage = (appId, user) => {
|
||||
if (!user) {
|
||||
return
|
||||
}
|
||||
return generateAppUserID(getProdAppID(appId), user._id)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -89,6 +89,14 @@ jest.spyOn(events.user, "passwordUpdated")
|
|||
jest.spyOn(events.user, "passwordResetRequested")
|
||||
jest.spyOn(events.user, "passwordReset")
|
||||
|
||||
jest.spyOn(events.group, "created")
|
||||
jest.spyOn(events.group, "updated")
|
||||
jest.spyOn(events.group, "deleted")
|
||||
jest.spyOn(events.group, "usersAdded")
|
||||
jest.spyOn(events.group, "usersDeleted")
|
||||
jest.spyOn(events.group, "createdOnboarding")
|
||||
jest.spyOn(events.group, "permissionsEdited")
|
||||
|
||||
jest.spyOn(events.serve, "servedBuilder")
|
||||
jest.spyOn(events.serve, "servedApp")
|
||||
jest.spyOn(events.serve, "servedAppPreview")
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
["XXS", "--spectrum-alias-avatar-size-50"],
|
||||
["XS", "--spectrum-alias-avatar-size-75"],
|
||||
["S", "--spectrum-alias-avatar-size-200"],
|
||||
["M", "--spectrum-alias-avatar-size-300"],
|
||||
["M", "--spectrum-alias-avatar-size-400"],
|
||||
["L", "--spectrum-alias-avatar-size-500"],
|
||||
["XL", "--spectrum-alias-avatar-size-600"],
|
||||
["XXL", "--spectrum-alias-avatar-size-700"],
|
||||
|
@ -13,6 +13,19 @@
|
|||
export let url = ""
|
||||
export let disabled = false
|
||||
export let initials = "JD"
|
||||
|
||||
const DefaultColor = "#3aab87"
|
||||
|
||||
$: color = getColor(initials)
|
||||
|
||||
const getColor = initials => {
|
||||
if (!initials?.length) {
|
||||
return DefaultColor
|
||||
}
|
||||
const code = initials[0].toLowerCase().charCodeAt(0)
|
||||
const hue = ((code % 26) / 26) * 360
|
||||
return `hsl(${hue}, 50%, 50%)`
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if url}
|
||||
|
@ -25,10 +38,11 @@
|
|||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="spectrum-Avatar"
|
||||
class:is-disabled={disabled}
|
||||
style="width: var({sizes.get(size)}); height: var({sizes.get(
|
||||
size
|
||||
)}); font-size: calc(var({sizes.get(size)}) / 2)"
|
||||
)}); font-size: calc(var({sizes.get(size)}) / 2); background: {color};"
|
||||
>
|
||||
{initials || ""}
|
||||
</div>
|
||||
|
@ -40,7 +54,6 @@
|
|||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 600;
|
||||
background: #3aab87;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
|
|
|
@ -0,0 +1,218 @@
|
|||
<script>
|
||||
import "@spectrum-css/inputgroup/dist/index-vars.css"
|
||||
import "@spectrum-css/popover/dist/index-vars.css"
|
||||
import "@spectrum-css/menu/dist/index-vars.css"
|
||||
import { fly } from "svelte/transition"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
import clickOutside from "../../Actions/click_outside"
|
||||
|
||||
export let inputValue
|
||||
export let dropdownValue
|
||||
export let id = null
|
||||
export let inputType = "text"
|
||||
export let placeholder = "Choose an option or type"
|
||||
export let disabled = false
|
||||
export let readonly = false
|
||||
export let updateOnChange = true
|
||||
export let error = null
|
||||
export let options = []
|
||||
export let getOptionLabel = option => extractProperty(option, "label")
|
||||
export let getOptionValue = option => extractProperty(option, "value")
|
||||
|
||||
export let isOptionSelected = () => false
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
let open = false
|
||||
let focus = false
|
||||
|
||||
$: fieldText = getFieldText(dropdownValue, options, placeholder)
|
||||
|
||||
const getFieldText = (dropdownValue, options, placeholder) => {
|
||||
// Always use placeholder if no value
|
||||
if (dropdownValue == null || dropdownValue === "") {
|
||||
return placeholder || "Choose an option or type"
|
||||
}
|
||||
|
||||
// Wait for options to load if there is a value but no options
|
||||
if (!options?.length) {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Render the label if the selected option is found, otherwise raw value
|
||||
const selected = options.find(
|
||||
option => getOptionValue(option) === dropdownValue
|
||||
)
|
||||
return selected ? getOptionLabel(selected) : dropdownValue
|
||||
}
|
||||
|
||||
const updateValue = newValue => {
|
||||
if (readonly) {
|
||||
return
|
||||
}
|
||||
dispatch("change", newValue)
|
||||
}
|
||||
|
||||
const onFocus = () => {
|
||||
if (readonly) {
|
||||
return
|
||||
}
|
||||
focus = true
|
||||
}
|
||||
|
||||
const onBlur = event => {
|
||||
if (readonly) {
|
||||
return
|
||||
}
|
||||
focus = false
|
||||
updateValue(event.target.value)
|
||||
}
|
||||
|
||||
const onInput = event => {
|
||||
if (readonly || !updateOnChange) {
|
||||
return
|
||||
}
|
||||
updateValue(event.target.value)
|
||||
}
|
||||
|
||||
const updateValueOnEnter = event => {
|
||||
if (readonly) {
|
||||
return
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
updateValue(event.target.value)
|
||||
}
|
||||
}
|
||||
|
||||
const onClick = () => {
|
||||
dispatch("click")
|
||||
if (readonly) {
|
||||
return
|
||||
}
|
||||
open = true
|
||||
}
|
||||
|
||||
const onPick = newValue => {
|
||||
dispatch("pick", newValue)
|
||||
open = false
|
||||
}
|
||||
|
||||
const extractProperty = (value, property) => {
|
||||
if (value && typeof value === "object") {
|
||||
return value[property]
|
||||
}
|
||||
return value
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="spectrum-InputGroup"
|
||||
class:is-invalid={!!error}
|
||||
class:is-disabled={disabled}
|
||||
>
|
||||
<div
|
||||
class="spectrum-Textfield spectrum-InputGroup-textfield"
|
||||
class:is-invalid={!!error}
|
||||
class:is-disabled={disabled}
|
||||
class:is-focused={focus}
|
||||
>
|
||||
<input
|
||||
{id}
|
||||
on:click
|
||||
on:blur
|
||||
on:focus
|
||||
on:input
|
||||
on:keyup
|
||||
on:blur={onBlur}
|
||||
on:focus={onFocus}
|
||||
on:input={onInput}
|
||||
on:keyup={updateValueOnEnter}
|
||||
value={inputValue || ""}
|
||||
placeholder={placeholder || ""}
|
||||
{disabled}
|
||||
{readonly}
|
||||
{inputType}
|
||||
class="spectrum-Textfield-input spectrum-InputGroup-input"
|
||||
/>
|
||||
</div>
|
||||
<div style="width: 30%">
|
||||
<button
|
||||
{id}
|
||||
class="spectrum-Picker spectrum-Picker--sizeM override-borders"
|
||||
{disabled}
|
||||
class:is-open={open}
|
||||
aria-haspopup="listbox"
|
||||
on:mousedown={onClick}
|
||||
>
|
||||
<span class="spectrum-Picker-label">
|
||||
<div>
|
||||
{fieldText}
|
||||
</div></span
|
||||
>
|
||||
<svg
|
||||
class="spectrum-Icon spectrum-UIIcon-ChevronDown100 spectrum-Picker-menuIcon"
|
||||
focusable="false"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use xlink:href="#spectrum-css-icon-Chevron100" />
|
||||
</svg>
|
||||
</button>
|
||||
{#if open}
|
||||
<div
|
||||
use:clickOutside={() => (open = false)}
|
||||
transition:fly|local={{ y: -20, duration: 200 }}
|
||||
class="spectrum-Popover spectrum-Popover--bottom spectrum-Picker-popover is-open"
|
||||
>
|
||||
<ul class="spectrum-Menu" role="listbox">
|
||||
{#each options as option, idx}
|
||||
<li
|
||||
class="spectrum-Menu-item"
|
||||
class:is-selected={isOptionSelected(getOptionValue(option, idx))}
|
||||
role="option"
|
||||
aria-selected="true"
|
||||
tabindex="0"
|
||||
on:click={() => onPick(getOptionValue(option, idx))}
|
||||
>
|
||||
<span class="spectrum-Menu-itemLabel">
|
||||
{getOptionLabel(option, idx)}
|
||||
</span>
|
||||
<svg
|
||||
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon"
|
||||
focusable="false"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use xlink:href="#spectrum-css-icon-Checkmark100" />
|
||||
</svg>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.spectrum-InputGroup {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.spectrum-InputGroup-input {
|
||||
border-right-width: 1px;
|
||||
}
|
||||
.spectrum-Textfield {
|
||||
width: 100%;
|
||||
}
|
||||
.spectrum-Textfield-input {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.override-borders {
|
||||
border-top-left-radius: 0px;
|
||||
border-bottom-left-radius: 0px;
|
||||
}
|
||||
.spectrum-Popover {
|
||||
max-height: 240px;
|
||||
z-index: 999;
|
||||
top: 100%;
|
||||
}
|
||||
</style>
|
|
@ -13,6 +13,7 @@
|
|||
export let readonly = false
|
||||
export let autocomplete = false
|
||||
export let sort = false
|
||||
export let autoWidth = false
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
$: selectedLookupMap = getSelectedLookupMap(value)
|
||||
|
@ -85,4 +86,5 @@
|
|||
{getOptionValue}
|
||||
onSelectOption={toggleOption}
|
||||
{sort}
|
||||
{autoWidth}
|
||||
/>
|
||||
|
|
|
@ -0,0 +1,430 @@
|
|||
<script>
|
||||
import "@spectrum-css/inputgroup/dist/index-vars.css"
|
||||
import "@spectrum-css/popover/dist/index-vars.css"
|
||||
import "@spectrum-css/menu/dist/index-vars.css"
|
||||
import { fly } from "svelte/transition"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
import clickOutside from "../../Actions/click_outside"
|
||||
import Icon from "../../Icon/Icon.svelte"
|
||||
import StatusLight from "../../StatusLight/StatusLight.svelte"
|
||||
import Detail from "../../Typography/Detail.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 readonly = false
|
||||
export let updateOnChange = true
|
||||
export let error = null
|
||||
export let secondaryOptions = []
|
||||
export let primaryOptions = []
|
||||
export let secondaryFieldText = ""
|
||||
export let secondaryFieldIcon = ""
|
||||
export let secondaryFieldColour = ""
|
||||
export let getPrimaryOptionLabel = option => option
|
||||
export let getPrimaryOptionValue = option => option
|
||||
export let getPrimaryOptionColour = () => null
|
||||
export let getPrimaryOptionIcon = () => null
|
||||
export let getSecondaryOptionLabel = option => option
|
||||
export let getSecondaryOptionValue = option => option
|
||||
export let getSecondaryOptionColour = () => null
|
||||
export let onSelectOption = () => {}
|
||||
export let autoWidth = false
|
||||
export let autocomplete = false
|
||||
export let isOptionSelected = () => false
|
||||
export let isPlaceholder = false
|
||||
export let placeholderOption = null
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
let primaryOpen = false
|
||||
let secondaryOpen = false
|
||||
let focus = false
|
||||
let searchTerm = null
|
||||
|
||||
$: groupTitles = Object.keys(primaryOptions)
|
||||
$: filteredOptions = getFilteredOptions(
|
||||
primaryOptions,
|
||||
searchTerm,
|
||||
getPrimaryOptionLabel
|
||||
)
|
||||
let iconData
|
||||
/*
|
||||
$: iconData = primaryOptions?.find(x => {
|
||||
return x.name === primaryFieldText
|
||||
})
|
||||
*/
|
||||
const updateValue = newValue => {
|
||||
if (readonly) {
|
||||
return
|
||||
}
|
||||
dispatch("change", newValue)
|
||||
}
|
||||
|
||||
const onClickSecondary = () => {
|
||||
dispatch("click")
|
||||
if (readonly) {
|
||||
return
|
||||
}
|
||||
secondaryOpen = true
|
||||
}
|
||||
|
||||
const onPickPrimary = newValue => {
|
||||
dispatch("pickprimary", newValue)
|
||||
primaryOpen = false
|
||||
}
|
||||
|
||||
const onClearPrimary = () => {
|
||||
dispatch("pickprimary", null)
|
||||
primaryOpen = false
|
||||
}
|
||||
|
||||
const onPickSecondary = newValue => {
|
||||
dispatch("picksecondary", newValue)
|
||||
secondaryOpen = false
|
||||
}
|
||||
|
||||
const onBlur = event => {
|
||||
if (readonly) {
|
||||
return
|
||||
}
|
||||
focus = false
|
||||
updateValue(event.target.value)
|
||||
}
|
||||
|
||||
const onInput = event => {
|
||||
if (readonly || !updateOnChange) {
|
||||
return
|
||||
}
|
||||
updateValue(event.target.value)
|
||||
}
|
||||
|
||||
const updateValueOnEnter = event => {
|
||||
if (readonly) {
|
||||
return
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
updateValue(event.target.value)
|
||||
}
|
||||
}
|
||||
|
||||
const getFilteredOptions = (options, term, getLabel) => {
|
||||
if (autocomplete && term) {
|
||||
const lowerCaseTerm = term.toLowerCase()
|
||||
return options.filter(option => {
|
||||
return `${getLabel(option)}`.toLowerCase().includes(lowerCaseTerm)
|
||||
})
|
||||
}
|
||||
return options
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="spectrum-InputGroup"
|
||||
class:is-invalid={!!error}
|
||||
class:is-disabled={disabled}
|
||||
>
|
||||
<div
|
||||
class="spectrum-Textfield spectrum-InputGroup-textfield"
|
||||
class:is-invalid={!!error}
|
||||
class:is-disabled={disabled}
|
||||
class:is-focused={focus}
|
||||
class:is-full-width={!secondaryOptions.length}
|
||||
>
|
||||
{#if iconData}
|
||||
<svg
|
||||
width="16px"
|
||||
height="16px"
|
||||
class="spectrum-Icon iconPadding"
|
||||
style="color: {iconData?.color}"
|
||||
focusable="false"
|
||||
>
|
||||
<use xlink:href="#spectrum-icon-18-{iconData?.icon}" />
|
||||
</svg>
|
||||
{/if}
|
||||
<input
|
||||
{id}
|
||||
on:click={() => (primaryOpen = true)}
|
||||
on:blur
|
||||
on:focus
|
||||
on:input
|
||||
on:keyup
|
||||
on:blur={onBlur}
|
||||
on:input={onInput}
|
||||
on:keyup={updateValueOnEnter}
|
||||
value={primaryLabel || ""}
|
||||
placeholder={placeholder || ""}
|
||||
{disabled}
|
||||
{readonly}
|
||||
class="spectrum-Textfield-input spectrum-InputGroup-input"
|
||||
class:labelPadding={iconData}
|
||||
/>
|
||||
{#if primaryValue}
|
||||
<button
|
||||
on:click={() => onClearPrimary()}
|
||||
type="reset"
|
||||
class="spectrum-ClearButton spectrum-Search-clearButton"
|
||||
>
|
||||
<svg
|
||||
class="spectrum-Icon spectrum-UIIcon-Cross75"
|
||||
focusable="false"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use xlink:href="#spectrum-css-icon-Cross75" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if primaryOpen}
|
||||
<div
|
||||
use:clickOutside={() => (primaryOpen = false)}
|
||||
transition:fly|local={{ y: -20, duration: 200 }}
|
||||
class="spectrum-Popover spectrum-Popover--bottom spectrum-Picker-popover is-open"
|
||||
class:auto-width={autoWidth}
|
||||
class:is-full-width={!secondaryOptions.length}
|
||||
>
|
||||
<ul class="spectrum-Menu" role="listbox">
|
||||
{#if placeholderOption}
|
||||
<li
|
||||
class="spectrum-Menu-item placeholder"
|
||||
class:is-selected={isPlaceholder}
|
||||
role="option"
|
||||
aria-selected="true"
|
||||
tabindex="0"
|
||||
on:click={() => onSelectOption(null)}
|
||||
>
|
||||
<span class="spectrum-Menu-itemLabel">{placeholderOption}</span>
|
||||
<svg
|
||||
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon"
|
||||
focusable="false"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use xlink:href="#spectrum-css-icon-Checkmark100" />
|
||||
</svg>
|
||||
</li>
|
||||
{/if}
|
||||
{#each groupTitles as title}
|
||||
<div class="spectrum-Menu-item">
|
||||
<Detail>{title}</Detail>
|
||||
</div>
|
||||
{#if primaryOptions}
|
||||
{#each primaryOptions[title].data as option, idx}
|
||||
<li
|
||||
class="spectrum-Menu-item"
|
||||
class:is-selected={isOptionSelected(
|
||||
getPrimaryOptionValue(option, idx)
|
||||
)}
|
||||
role="option"
|
||||
aria-selected="true"
|
||||
tabindex="0"
|
||||
on:click={() =>
|
||||
onPickPrimary({
|
||||
value: primaryOptions[title].getValue(option),
|
||||
label: primaryOptions[title].getLabel(option),
|
||||
})}
|
||||
>
|
||||
{#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>
|
||||
{:else if getPrimaryOptionColour(option, idx)}
|
||||
<span class="option-left">
|
||||
<StatusLight color={getPrimaryOptionColour(option, idx)} />
|
||||
</span>
|
||||
{/if}
|
||||
<span class="spectrum-Menu-itemLabel">
|
||||
<span
|
||||
class:spacing-group={primaryOptions[title].getIcon(option)}
|
||||
>
|
||||
{primaryOptions[title].getLabel(option)}
|
||||
<span />
|
||||
</span>
|
||||
<svg
|
||||
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon"
|
||||
focusable="false"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use xlink:href="#spectrum-css-icon-Checkmark100" />
|
||||
</svg>
|
||||
{#if getPrimaryOptionIcon(option, idx) && getPrimaryOptionColour(option, idx)}
|
||||
<span class="option-right">
|
||||
<StatusLight
|
||||
color={getPrimaryOptionColour(option, idx)}
|
||||
/>
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{#if secondaryOptions.length}
|
||||
<div style="width: 30%">
|
||||
<button
|
||||
{id}
|
||||
class="spectrum-Picker spectrum-Picker--sizeM override-borders"
|
||||
{disabled}
|
||||
class:is-open={secondaryOpen}
|
||||
aria-haspopup="listbox"
|
||||
on:mousedown={onClickSecondary}
|
||||
>
|
||||
{#if secondaryFieldIcon}
|
||||
<span class="option-left">
|
||||
<Icon name={secondaryFieldIcon} />
|
||||
</span>
|
||||
{:else if secondaryFieldColour}
|
||||
<span class="option-left">
|
||||
<StatusLight color={secondaryFieldColour} />
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<span class:auto-width={autoWidth} class="spectrum-Picker-label">
|
||||
{secondaryFieldText}
|
||||
</span>
|
||||
<svg
|
||||
class="spectrum-Icon spectrum-UIIcon-ChevronDown100 spectrum-Picker-menuIcon"
|
||||
focusable="false"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use xlink:href="#spectrum-css-icon-Chevron100" />
|
||||
</svg>
|
||||
</button>
|
||||
{#if secondaryOpen}
|
||||
<div
|
||||
use:clickOutside={() => (secondaryOpen = false)}
|
||||
transition:fly|local={{ y: -20, duration: 200 }}
|
||||
class="spectrum-Popover spectrum-Popover--bottom spectrum-Picker-popover is-open"
|
||||
style="width: 30%"
|
||||
>
|
||||
<ul class="spectrum-Menu" role="listbox">
|
||||
{#each secondaryOptions as option, idx}
|
||||
<li
|
||||
class="spectrum-Menu-item"
|
||||
class:is-selected={isOptionSelected(
|
||||
getSecondaryOptionValue(option, idx)
|
||||
)}
|
||||
role="option"
|
||||
aria-selected="true"
|
||||
tabindex="0"
|
||||
on:click={() =>
|
||||
onPickSecondary(getSecondaryOptionValue(option, idx))}
|
||||
>
|
||||
{#if getSecondaryOptionColour(option, idx)}
|
||||
<span class="option-left">
|
||||
<StatusLight
|
||||
color={getSecondaryOptionColour(option, idx)}
|
||||
/>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<span class="spectrum-Menu-itemLabel">
|
||||
{getSecondaryOptionLabel(option, idx)}
|
||||
</span>
|
||||
<svg
|
||||
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon"
|
||||
focusable="false"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use xlink:href="#spectrum-css-icon-Checkmark100" />
|
||||
</svg>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.spacing-group {
|
||||
margin-left: var(--spacing-m);
|
||||
}
|
||||
.spectrum-InputGroup {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.override-borders {
|
||||
border-top-left-radius: 0px;
|
||||
border-bottom-left-radius: 0px;
|
||||
}
|
||||
|
||||
.spectrum-Popover {
|
||||
max-height: 240px;
|
||||
z-index: 999;
|
||||
top: 100%;
|
||||
}
|
||||
|
||||
.option-left {
|
||||
padding-right: 8px;
|
||||
}
|
||||
.option-right {
|
||||
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%;
|
||||
left: 10px;
|
||||
transform: translateY(-50%);
|
||||
color: silver;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.labelPadding {
|
||||
padding-left: calc(1em + 10px + 8px);
|
||||
}
|
||||
|
||||
.spectrum-Textfield.spectrum-InputGroup-textfield {
|
||||
width: 70%;
|
||||
}
|
||||
.spectrum-Textfield.spectrum-InputGroup-textfield.is-full-width {
|
||||
width: 100%;
|
||||
}
|
||||
.spectrum-Textfield.spectrum-InputGroup-textfield.is-full-width input {
|
||||
border-right-width: thin;
|
||||
}
|
||||
|
||||
.spectrum-Popover.spectrum-Popover--bottom.spectrum-Picker-popover.is-open {
|
||||
width: 70%;
|
||||
}
|
||||
.spectrum-Popover.spectrum-Popover--bottom.spectrum-Picker-popover.is-open.is-full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.spectrum-Search-clearButton {
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
|
@ -17,7 +17,6 @@
|
|||
export let autoWidth = false
|
||||
export let autocomplete = false
|
||||
export let sort = false
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
let open = false
|
||||
$: fieldText = getFieldText(value, options, placeholder)
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
<script>
|
||||
import Field from "./Field.svelte"
|
||||
import InputDropdown from "./Core/InputDropdown.svelte"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
|
||||
export let inputValue = null
|
||||
export let dropdownValue = null
|
||||
export let inputType = "text"
|
||||
export let label = null
|
||||
export let labelPosition = "above"
|
||||
export let placeholder = null
|
||||
export let disabled = false
|
||||
export let readonly = false
|
||||
export let error = null
|
||||
export let updateOnChange = true
|
||||
export let quiet = false
|
||||
export let dataCy
|
||||
export let autofocus
|
||||
export let options = []
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
const onPick = e => {
|
||||
dropdownValue = e.detail
|
||||
dispatch("pick", e.detail)
|
||||
}
|
||||
const onChange = e => {
|
||||
inputValue = e.detail
|
||||
dispatch("change", e.detail)
|
||||
}
|
||||
</script>
|
||||
|
||||
<Field {label} {labelPosition} {error}>
|
||||
<InputDropdown
|
||||
{dataCy}
|
||||
{updateOnChange}
|
||||
{error}
|
||||
{disabled}
|
||||
{readonly}
|
||||
{inputValue}
|
||||
{dropdownValue}
|
||||
{placeholder}
|
||||
{inputType}
|
||||
{quiet}
|
||||
{autofocus}
|
||||
{options}
|
||||
on:change={onChange}
|
||||
on:pick={onPick}
|
||||
on:click
|
||||
on:input
|
||||
on:blur
|
||||
on:focus
|
||||
on:keyup
|
||||
/>
|
||||
</Field>
|
|
@ -14,7 +14,7 @@
|
|||
export let getOptionLabel = option => option
|
||||
export let getOptionValue = option => option
|
||||
export let sort = false
|
||||
|
||||
export let autoWidth = false
|
||||
const dispatch = createEventDispatcher()
|
||||
const onChange = e => {
|
||||
value = e.detail
|
||||
|
@ -33,6 +33,7 @@
|
|||
{sort}
|
||||
{getOptionLabel}
|
||||
{getOptionValue}
|
||||
{autoWidth}
|
||||
on:change={onChange}
|
||||
on:click
|
||||
/>
|
||||
|
|
|
@ -0,0 +1,125 @@
|
|||
<script>
|
||||
import Field from "./Field.svelte"
|
||||
import PickerDropdown from "./Core/PickerDropdown.svelte"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
|
||||
export let primaryValue = null
|
||||
export let secondaryValue = null
|
||||
export let inputType = "text"
|
||||
export let label = null
|
||||
export let labelPosition = "above"
|
||||
export let secondaryPlaceholder = null
|
||||
export let autocomplete
|
||||
export let placeholder = null
|
||||
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 =>
|
||||
extractProperty(option, "value")
|
||||
export let getSecondaryOptionColour = () => {}
|
||||
export let getSecondaryOptionIcon = () => {}
|
||||
export let quiet = false
|
||||
export let dataCy
|
||||
export let autofocus
|
||||
export let primaryOptions = []
|
||||
export let secondaryOptions = []
|
||||
|
||||
let primaryLabel
|
||||
let secondaryLabel
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
$: secondaryFieldText = getSecondaryFieldText(
|
||||
secondaryValue,
|
||||
secondaryOptions,
|
||||
secondaryPlaceholder
|
||||
)
|
||||
$: secondaryFieldIcon = getSecondaryFieldAttribute(
|
||||
getSecondaryOptionIcon,
|
||||
secondaryValue,
|
||||
secondaryOptions
|
||||
)
|
||||
$: secondaryFieldColour = getSecondaryFieldAttribute(
|
||||
getSecondaryOptionColour,
|
||||
secondaryValue,
|
||||
secondaryOptions
|
||||
)
|
||||
|
||||
const getSecondaryFieldAttribute = (getAttribute, value, options) => {
|
||||
// Wait for options to load if there is a value but no options
|
||||
|
||||
if (!options?.length) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const index = options.findIndex(
|
||||
(option, idx) => getSecondaryOptionValue(option, idx) === value
|
||||
)
|
||||
|
||||
return index !== -1 ? getAttribute(options[index], index) : null
|
||||
}
|
||||
|
||||
const getSecondaryFieldText = (value, options, placeholder) => {
|
||||
// Always use placeholder if no value
|
||||
if (value == null || value === "") {
|
||||
return placeholder || "Choose an option"
|
||||
}
|
||||
|
||||
return getSecondaryFieldAttribute(getSecondaryOptionLabel, value, options)
|
||||
}
|
||||
|
||||
const onPickPrimary = e => {
|
||||
primaryLabel = e?.detail?.label || null
|
||||
primaryValue = e?.detail?.value || null
|
||||
dispatch("pickprimary", e?.detail?.value || {})
|
||||
}
|
||||
|
||||
const onPickSecondary = e => {
|
||||
secondaryValue = e.detail
|
||||
dispatch("picksecondary", e.detail)
|
||||
}
|
||||
|
||||
const extractProperty = (value, property) => {
|
||||
if (value && typeof value === "object") {
|
||||
return value[property]
|
||||
}
|
||||
return value
|
||||
}
|
||||
</script>
|
||||
|
||||
<Field {label} {labelPosition} {error}>
|
||||
<PickerDropdown
|
||||
{autocomplete}
|
||||
{dataCy}
|
||||
{updateOnChange}
|
||||
{error}
|
||||
{disabled}
|
||||
{readonly}
|
||||
{placeholder}
|
||||
{inputType}
|
||||
{quiet}
|
||||
{autofocus}
|
||||
{primaryOptions}
|
||||
{secondaryOptions}
|
||||
{getSecondaryOptionLabel}
|
||||
{getSecondaryOptionValue}
|
||||
{getSecondaryOptionIcon}
|
||||
{getSecondaryOptionColour}
|
||||
{secondaryFieldText}
|
||||
{secondaryFieldIcon}
|
||||
{secondaryFieldColour}
|
||||
{primaryValue}
|
||||
{secondaryValue}
|
||||
{primaryLabel}
|
||||
{secondaryLabel}
|
||||
on:pickprimary={onPickPrimary}
|
||||
on:picksecondary={onPickSecondary}
|
||||
on:click
|
||||
on:input
|
||||
on:blur
|
||||
on:focus
|
||||
on:keyup
|
||||
/>
|
||||
</Field>
|
|
@ -0,0 +1,177 @@
|
|||
<script>
|
||||
//import { createEventDispatcher } from "svelte"
|
||||
import "@spectrum-css/popover/dist/index-vars.css"
|
||||
import clickOutside from "../Actions/click_outside"
|
||||
import { fly } from "svelte/transition"
|
||||
import Icon from "../Icon/Icon.svelte"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
|
||||
export let value
|
||||
export let size = "M"
|
||||
export let alignRight = false
|
||||
|
||||
let open = false
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
const iconList = [
|
||||
{
|
||||
label: "Icons",
|
||||
icons: [
|
||||
"Apps",
|
||||
"Actions",
|
||||
"ConversionFunnel",
|
||||
"App",
|
||||
"Briefcase",
|
||||
"Money",
|
||||
"ShoppingCart",
|
||||
"Form",
|
||||
"Help",
|
||||
"Monitoring",
|
||||
"Sandbox",
|
||||
"Project",
|
||||
"Organisations",
|
||||
"Magnify",
|
||||
"Launch",
|
||||
"Car",
|
||||
"Camera",
|
||||
"Bug",
|
||||
"Channel",
|
||||
"Calculator",
|
||||
"Calendar",
|
||||
"GraphDonut",
|
||||
"GraphBarHorizontal",
|
||||
"Demographic",
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const onChange = value => {
|
||||
dispatch("change", value)
|
||||
open = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<div class="preview size--{size || 'M'}" on:click={() => (open = true)}>
|
||||
<div
|
||||
class="fill"
|
||||
style={value ? `background: ${value};` : ""}
|
||||
class:placeholder={!value}
|
||||
>
|
||||
<Icon name={value || "UserGroup"} />
|
||||
</div>
|
||||
</div>
|
||||
{#if open}
|
||||
<div
|
||||
use:clickOutside={() => (open = false)}
|
||||
transition:fly={{ y: -20, duration: 200 }}
|
||||
class="spectrum-Popover spectrum-Popover--bottom spectrum-Picker-popover is-open"
|
||||
class:spectrum-Popover--align-right={alignRight}
|
||||
>
|
||||
{#each iconList as icon}
|
||||
<div class="category">
|
||||
<div class="heading">{icon.label}</div>
|
||||
<div class="icons">
|
||||
{#each icon.icons as icon}
|
||||
<div
|
||||
on:click={() => {
|
||||
onChange(icon)
|
||||
}}
|
||||
>
|
||||
<Icon name={icon} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
position: relative;
|
||||
}
|
||||
.preview {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
position: relative;
|
||||
box-shadow: 0 0 0 1px var(--spectrum-global-color-gray-400);
|
||||
}
|
||||
.preview:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
.fill {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.size--S {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.size--M {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
.size--L {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
.spectrum-Popover {
|
||||
width: 210px;
|
||||
z-index: 999;
|
||||
top: 100%;
|
||||
padding: var(--spacing-l) var(--spacing-xl);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
gap: var(--spacing-xl);
|
||||
}
|
||||
.spectrum-Popover--align-right {
|
||||
right: 0;
|
||||
}
|
||||
.icons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr;
|
||||
gap: var(--spacing-m);
|
||||
}
|
||||
.heading {
|
||||
font-size: var(--font-size-s);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14px;
|
||||
flex: 1 1 auto;
|
||||
text-transform: uppercase;
|
||||
grid-column: 1 / 5;
|
||||
margin-bottom: var(--spacing-s);
|
||||
}
|
||||
|
||||
.icon {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
border-radius: 100%;
|
||||
box-shadow: 0 0 0 1px var(--spectrum-global-color-gray-300);
|
||||
position: relative;
|
||||
}
|
||||
.icon:hover {
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 2px 2px var(--spectrum-global-color-gray-300);
|
||||
}
|
||||
.custom {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
gap: var(--spacing-m);
|
||||
margin-right: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.spectrum-wrapper {
|
||||
background-color: transparent;
|
||||
}
|
||||
</style>
|
|
@ -1,53 +0,0 @@
|
|||
<script>
|
||||
import { View } from "svench";
|
||||
import DetailSummary from "./DetailSummary.svelte";
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link href="https://cdn.jsdelivr.net/npm/remixicon@2.5.0/fonts/remixicon.css" rel="stylesheet">
|
||||
</svelte:head>
|
||||
|
||||
<style>
|
||||
div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
gap: var(--spacing-m);
|
||||
width: 120px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<View name="default">
|
||||
<div>
|
||||
<DetailSummary name="Category 1">
|
||||
<span>1</span>
|
||||
<span>2</span>
|
||||
<span>3</span>
|
||||
<span>4</span>
|
||||
</DetailSummary>
|
||||
<DetailSummary name="Category 2">
|
||||
<span>1</span>
|
||||
<span>2</span>
|
||||
<span>3</span>
|
||||
<span>4</span>
|
||||
</DetailSummary>
|
||||
</div>
|
||||
</View>
|
||||
|
||||
<View name="thin">
|
||||
<div>
|
||||
<DetailSummary thin name="Category 1">
|
||||
<span>1</span>
|
||||
<span>2</span>
|
||||
<span>3</span>
|
||||
<span>4</span>
|
||||
</DetailSummary>
|
||||
<DetailSummary thin name="Category 2">
|
||||
<span>1</span>
|
||||
<span>2</span>
|
||||
<span>3</span>
|
||||
<span>4</span>
|
||||
</DetailSummary>
|
||||
</div>
|
||||
</View>
|
|
@ -0,0 +1,28 @@
|
|||
<script>
|
||||
import Detail from "../Typography/Detail.svelte"
|
||||
|
||||
export let title = null
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{#if title}
|
||||
<div class="title">
|
||||
<Detail>{title}</Detail>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="list-items">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.title {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.list-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,92 @@
|
|||
<script>
|
||||
import Body from "../Typography/Body.svelte"
|
||||
import Icon from "../Icon/Icon.svelte"
|
||||
import Label from "../Label/Label.svelte"
|
||||
import Avatar from "../Avatar/Avatar.svelte"
|
||||
|
||||
export let icon = null
|
||||
export let iconBackground = null
|
||||
export let avatar = false
|
||||
export let title = null
|
||||
export let subtitle = null
|
||||
|
||||
$: initials = avatar ? title?.[0] : null
|
||||
</script>
|
||||
|
||||
<div class="list-item">
|
||||
<div class="left">
|
||||
{#if icon}
|
||||
<div class="icon" style="background: {iconBackground || `transparent`};">
|
||||
<Icon name={icon} size="S" color={iconBackground ? "white" : null} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if avatar}
|
||||
<Avatar {initials} />
|
||||
{/if}
|
||||
{#if title}
|
||||
<Body>{title}</Body>
|
||||
{/if}
|
||||
{#if subtitle}
|
||||
<Label>{subtitle}</Label>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="right">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.list-item {
|
||||
padding: 0 16px;
|
||||
height: 56px;
|
||||
background: var(--spectrum-alias-background-color-tertiary);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
border: 1px solid var(--spectrum-global-color-gray-300);
|
||||
}
|
||||
.list-item:not(:first-child) {
|
||||
border-top: none;
|
||||
}
|
||||
.list-item:first-child {
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
.list-item:last-child {
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
.left,
|
||||
.right {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xl);
|
||||
}
|
||||
.left {
|
||||
width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.right {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.list-item :global(.spectrum-Icon),
|
||||
.list-item :global(.spectrum-Avatar) {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.list-item :global(.spectrum-Body) {
|
||||
color: var(--spectrum-global-color-gray-900);
|
||||
}
|
||||
.list-item :global(.spectrum-Body) {
|
||||
white-space: nowrap;
|
||||
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>
|
|
@ -37,6 +37,7 @@
|
|||
export let autoSortColumns = true
|
||||
export let compact = false
|
||||
export let customPlaceholder = false
|
||||
export let showHeaderBorder = true
|
||||
export let placeholderText = "No rows found"
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
@ -286,6 +287,7 @@
|
|||
<div class="spectrum-Table-head">
|
||||
{#if showEditColumn}
|
||||
<div
|
||||
class:noBorderHeader={!showHeaderBorder}
|
||||
class="spectrum-Table-headCell spectrum-Table-headCell--divider spectrum-Table-headCell--edit"
|
||||
>
|
||||
{#if allowSelectRows}
|
||||
|
@ -301,6 +303,7 @@
|
|||
{#each fields as field}
|
||||
<div
|
||||
class="spectrum-Table-headCell"
|
||||
class:noBorderHeader={!showHeaderBorder}
|
||||
class:spectrum-Table-headCell--alignCenter={schema[field]
|
||||
.align === "Center"}
|
||||
class:spectrum-Table-headCell--alignRight={schema[field].align ===
|
||||
|
@ -348,6 +351,7 @@
|
|||
<div class="spectrum-Table-row">
|
||||
{#if showEditColumn}
|
||||
<div
|
||||
class:noBorderCheckbox={!showHeaderBorder}
|
||||
class="spectrum-Table-cell spectrum-Table-cell--divider spectrum-Table-cell--edit"
|
||||
on:click={e => {
|
||||
toggleSelectRow(row)
|
||||
|
@ -481,6 +485,18 @@
|
|||
.spectrum-Table-headCell:last-of-type {
|
||||
border-right: var(--table-border);
|
||||
}
|
||||
|
||||
.noBorderHeader {
|
||||
border-top: none !important;
|
||||
border-right: none !important;
|
||||
border-left: none !important;
|
||||
}
|
||||
|
||||
.noBorderCheckbox {
|
||||
border-top: none !important;
|
||||
border-right: none !important;
|
||||
}
|
||||
|
||||
.spectrum-Table-headCell--alignCenter {
|
||||
justify-content: center;
|
||||
}
|
||||
|
@ -499,7 +515,7 @@
|
|||
z-index: 3;
|
||||
}
|
||||
.spectrum-Table-headCell .title {
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.spectrum-Table-headCell:hover .spectrum-Table-editIcon {
|
||||
|
@ -562,7 +578,7 @@
|
|||
gap: 4px;
|
||||
border-bottom: 1px solid var(--spectrum-alias-border-color-mid);
|
||||
background-color: var(--table-bg);
|
||||
z-index: 1;
|
||||
z-index: auto;
|
||||
}
|
||||
.spectrum-Table-cell--divider {
|
||||
padding-right: var(--cell-padding);
|
||||
|
@ -570,6 +586,7 @@
|
|||
.spectrum-Table-cell--divider + .spectrum-Table-cell {
|
||||
padding-left: var(--cell-padding);
|
||||
}
|
||||
|
||||
.spectrum-Table-cell--edit {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
|
|
|
@ -23,6 +23,8 @@ export { default as Icon, directions } from "./Icon/Icon.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"
|
||||
export { default as InputDropdown } from "./Form/InputDropdown.svelte"
|
||||
export { default as PickerDropdown } from "./Form/PickerDropdown.svelte"
|
||||
export { default as DetailSummary } from "./DetailSummary/DetailSummary.svelte"
|
||||
export { default as Popover } from "./Popover/Popover.svelte"
|
||||
export { default as ProgressBar } from "./ProgressBar/ProgressBar.svelte"
|
||||
|
@ -58,12 +60,15 @@ export { default as Pagination } from "./Pagination/Pagination.svelte"
|
|||
export { default as Badge } from "./Badge/Badge.svelte"
|
||||
export { default as StatusLight } from "./StatusLight/StatusLight.svelte"
|
||||
export { default as ColorPicker } from "./ColorPicker/ColorPicker.svelte"
|
||||
export { default as IconPicker } from "./IconPicker/IconPicker.svelte"
|
||||
export { default as InlineAlert } from "./InlineAlert/InlineAlert.svelte"
|
||||
export { default as Banner } from "./Banner/Banner.svelte"
|
||||
export { default as BannerDisplay } from "./Banner/BannerDisplay.svelte"
|
||||
export { default as MarkdownEditor } from "./Markdown/MarkdownEditor.svelte"
|
||||
export { default as MarkdownViewer } from "./Markdown/MarkdownViewer.svelte"
|
||||
export { default as RichTextField } from "./Form/RichTextField.svelte"
|
||||
export { default as List } from "./List/List.svelte"
|
||||
export { default as ListItem } from "./List/ListItem.svelte"
|
||||
export { default as IconSideNav } from "./IconSideNav/IconSideNav.svelte"
|
||||
export { default as IconSideNavItem } from "./IconSideNav/IconSideNavItem.svelte"
|
||||
export { default as Slider } from "./Form/Slider.svelte"
|
||||
|
@ -71,6 +76,7 @@ export { default as Slider } from "./Form/Slider.svelte"
|
|||
// Renderers
|
||||
export { default as BoldRenderer } from "./Table/BoldRenderer.svelte"
|
||||
export { default as CodeRenderer } from "./Table/CodeRenderer.svelte"
|
||||
export { default as InternalRenderer } from "./Table/InternalRenderer.svelte"
|
||||
|
||||
// Typography
|
||||
export { default as Body } from "./Typography/Body.svelte"
|
||||
|
|
|
@ -211,7 +211,6 @@
|
|||
bindings={getAuthBindings()}
|
||||
on:change={e => {
|
||||
form.bearer.token = e.detail
|
||||
console.log(e.detail)
|
||||
onFieldChange()
|
||||
}}
|
||||
on:blur={() => {
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
<script>
|
||||
import { Select } from "@budibase/bbui"
|
||||
import { roles } from "stores/backend"
|
||||
import { RoleUtils } from "@budibase/frontend-core"
|
||||
|
||||
export let value
|
||||
export let error
|
||||
export let placeholder = null
|
||||
export let autoWidth = false
|
||||
export let quiet = false
|
||||
</script>
|
||||
|
||||
<Select
|
||||
{autoWidth}
|
||||
{quiet}
|
||||
bind:value
|
||||
on:change
|
||||
options={$roles}
|
||||
getOptionLabel={role => role.name}
|
||||
getOptionValue={role => role._id}
|
||||
getOptionColour={role => RoleUtils.getRoleColour(role._id)}
|
||||
{placeholder}
|
||||
{error}
|
||||
/>
|
|
@ -0,0 +1,75 @@
|
|||
<script>
|
||||
import { ActionButton, Icon, Search, Divider, Detail } from "@budibase/bbui"
|
||||
|
||||
export let searchTerm = ""
|
||||
export let selected
|
||||
export let filtered = []
|
||||
export let addAll
|
||||
export let select
|
||||
export let title
|
||||
export let key
|
||||
</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>
|
||||
<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>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.header {
|
||||
align-items: center;
|
||||
padding: var(--spacing-m) 0 var(--spacing-m) 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.selection {
|
||||
align-items: end;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selection > :first-child {
|
||||
padding-top: var(--spacing-m);
|
||||
}
|
||||
|
||||
.sub-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
|
@ -111,7 +111,6 @@
|
|||
await admin.init()
|
||||
|
||||
// Create user
|
||||
await API.updateOwnMetadata({ roleId: $values.roleId })
|
||||
await auth.setInitInfo({})
|
||||
|
||||
// Create a default home screen if no template was selected
|
||||
|
|
|
@ -150,12 +150,31 @@ export function flipHeaderState(headersActivity) {
|
|||
return enabled
|
||||
}
|
||||
|
||||
export const parseToCsv = (headers, rows) => {
|
||||
let csv = headers?.map(key => `"${key}"`)?.join(",") || ""
|
||||
|
||||
for (let row of rows) {
|
||||
csv = `${csv}\n${headers
|
||||
.map(header => {
|
||||
let val = row[header]
|
||||
val =
|
||||
typeof val === "object" && !(val instanceof Date)
|
||||
? `"${JSON.stringify(val).replace(/"/g, "'")}"`
|
||||
: `"${val}"`
|
||||
return val.trim()
|
||||
})
|
||||
.join(",")}`
|
||||
}
|
||||
return csv
|
||||
}
|
||||
|
||||
export default {
|
||||
breakQueryString,
|
||||
buildQueryString,
|
||||
fieldsToSchema,
|
||||
flipHeaderState,
|
||||
keyValueToQueryParameters,
|
||||
parseToCsv,
|
||||
queryParametersToKeyValue,
|
||||
schemaToFields,
|
||||
}
|
||||
|
|
|
@ -50,7 +50,6 @@
|
|||
await store.actions.screens.save(duplicateScreen)
|
||||
} catch (error) {
|
||||
notifications.error("Error duplicating screen")
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
notifications,
|
||||
} from "@budibase/bbui"
|
||||
import { onMount } from "svelte"
|
||||
import { apps, organisation, auth } from "stores/portal"
|
||||
import { apps, organisation, auth, groups } from "stores/portal"
|
||||
import { goto } from "@roxi/routify"
|
||||
import { AppStatus } from "constants"
|
||||
import { gradient } from "actions"
|
||||
|
@ -30,20 +30,41 @@
|
|||
try {
|
||||
await organisation.init()
|
||||
await apps.load()
|
||||
await groups.actions.init()
|
||||
} catch (error) {
|
||||
notifications.error("Error loading apps")
|
||||
}
|
||||
loaded = true
|
||||
})
|
||||
|
||||
const publishedAppsOnly = app => app.status === AppStatus.DEPLOYED
|
||||
|
||||
$: userGroups = $groups.filter(group =>
|
||||
group.users.find(user => user._id === $auth.user?._id)
|
||||
)
|
||||
let userApps = []
|
||||
$: publishedApps = $apps.filter(publishedAppsOnly)
|
||||
$: userApps = $auth.user?.builder?.global
|
||||
? publishedApps
|
||||
: publishedApps.filter(app =>
|
||||
Object.keys($auth.user?.roles).includes(app.prodId)
|
||||
)
|
||||
|
||||
$: {
|
||||
if (!Object.keys($auth.user?.roles).length && $auth.user?.userGroups) {
|
||||
userApps = $auth.user?.builder?.global
|
||||
? publishedApps
|
||||
: publishedApps.filter(app => {
|
||||
return userGroups.find(group => {
|
||||
return Object.keys(group.roles)
|
||||
.map(role => apps.extractAppId(role))
|
||||
.includes(app.appId)
|
||||
})
|
||||
})
|
||||
} else {
|
||||
userApps = $auth.user?.builder?.global
|
||||
? publishedApps
|
||||
: publishedApps.filter(app =>
|
||||
Object.keys($auth.user?.roles)
|
||||
.map(x => apps.extractAppId(x))
|
||||
.includes(app.appId)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function getUrl(app) {
|
||||
if (app.url) {
|
||||
|
|
|
@ -52,6 +52,11 @@
|
|||
href: "/builder/portal/manage/users",
|
||||
heading: "Manage",
|
||||
},
|
||||
{
|
||||
title: "User Groups",
|
||||
href: "/builder/portal/manage/groups",
|
||||
},
|
||||
|
||||
{ title: "Auth", href: "/builder/portal/manage/auth" },
|
||||
{ title: "Email", href: "/builder/portal/manage/email" },
|
||||
{
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
<script>
|
||||
import { PickerDropdown, notifications } from "@budibase/bbui"
|
||||
import { groups } from "stores/portal"
|
||||
import { onMount, createEventDispatcher } from "svelte"
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
$: optionSections = {
|
||||
groups: {
|
||||
data: $groups,
|
||||
getLabel: group => group.name,
|
||||
getValue: group => group._id,
|
||||
getIcon: group => group.icon,
|
||||
getColour: group => group.color,
|
||||
},
|
||||
}
|
||||
|
||||
$: appData = [{ id: "", role: "" }]
|
||||
|
||||
$: onChange = selected => {
|
||||
const { detail } = selected
|
||||
if (!detail) return
|
||||
|
||||
const groupSelected = $groups.find(x => x._id === detail)
|
||||
const appIds = groupSelected?.apps.map(x => x.appId) || null
|
||||
dispatch("change", appIds)
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
await groups.actions.init()
|
||||
} catch (error) {
|
||||
notifications.error("Error")
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<PickerDropdown
|
||||
autocomplete
|
||||
primaryOptions={optionSections}
|
||||
placeholder={"Filter by access"}
|
||||
on:pickprimary={onChange}
|
||||
/>
|
|
@ -20,12 +20,14 @@
|
|||
import { store, automationStore } from "builderStore"
|
||||
import { API } from "api"
|
||||
import { onMount } from "svelte"
|
||||
import { apps, auth, admin, templates } from "stores/portal"
|
||||
import { apps, auth, admin, templates, groups } from "stores/portal"
|
||||
import download from "downloadjs"
|
||||
import { goto } from "@roxi/routify"
|
||||
import AppRow from "components/start/AppRow.svelte"
|
||||
import { AppStatus } from "constants"
|
||||
import Logo from "assets/bb-space-man.svg"
|
||||
import AccessFilter from "./_components/AcessFilter.svelte"
|
||||
import { Constants } from "@budibase/frontend-core"
|
||||
|
||||
let sortBy = "name"
|
||||
let template
|
||||
|
@ -39,6 +41,7 @@
|
|||
let cloud = $admin.cloud
|
||||
let creatingFromTemplate = false
|
||||
let automationErrors
|
||||
let accessFilterList = null
|
||||
|
||||
const resolveWelcomeMessage = (auth, apps) => {
|
||||
const userWelcome = auth?.user?.firstName
|
||||
|
@ -56,14 +59,20 @@
|
|||
: "Start from scratch"
|
||||
|
||||
$: enrichedApps = enrichApps($apps, $auth.user, sortBy)
|
||||
$: filteredApps = enrichedApps.filter(app =>
|
||||
app?.name?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
$: filteredApps = enrichedApps.filter(
|
||||
app =>
|
||||
app?.name?.toLowerCase().includes(searchTerm.toLowerCase()) &&
|
||||
(accessFilterList !== null ? accessFilterList.includes(app?.appId) : true)
|
||||
)
|
||||
|
||||
$: lockedApps = filteredApps.filter(app => app?.lockedYou || app?.lockedOther)
|
||||
$: unlocked = lockedApps?.length === 0
|
||||
$: automationErrors = getAutomationErrors(enrichedApps)
|
||||
|
||||
$: hasGroupsLicense = $auth.user?.license.features.includes(
|
||||
Constants.Features.USER_GROUPS
|
||||
)
|
||||
|
||||
const enrichApps = (apps, user, sortBy) => {
|
||||
const enrichedApps = apps.map(app => ({
|
||||
...app,
|
||||
|
@ -202,6 +211,10 @@
|
|||
$goto(`../../app/${app.devId}`)
|
||||
}
|
||||
|
||||
const accessFilterAction = accessFilter => {
|
||||
accessFilterList = accessFilter.detail
|
||||
}
|
||||
|
||||
function createAppFromTemplateUrl(templateKey) {
|
||||
// validate the template key just to make sure
|
||||
const templateParts = templateKey.split("/")
|
||||
|
@ -347,6 +360,9 @@
|
|||
</Button>
|
||||
{/if}
|
||||
<div class="filter">
|
||||
{#if hasGroupsLicense && $groups.length}
|
||||
<AccessFilter on:change={accessFilterAction} />
|
||||
{/if}
|
||||
<Select
|
||||
quiet
|
||||
autoWidth
|
||||
|
|
|
@ -9,10 +9,15 @@
|
|||
$redirect("../")
|
||||
}
|
||||
}
|
||||
|
||||
$: wide =
|
||||
$page.path.includes("email/:template") ||
|
||||
($page.path.includes("users") && !$page.path.includes(":userId")) ||
|
||||
($page.path.includes("groups") && !$page.path.includes(":groupId"))
|
||||
</script>
|
||||
|
||||
{#if $auth.isAdmin}
|
||||
<Page maxWidth="90ch" wide={$page.path.includes("email/:template")}>
|
||||
<Page maxWidth="90ch" {wide}>
|
||||
<slot />
|
||||
</Page>
|
||||
{/if}
|
||||
|
|
|
@ -0,0 +1,226 @@
|
|||
<script>
|
||||
import { goto } from "@roxi/routify"
|
||||
import {
|
||||
ActionButton,
|
||||
Button,
|
||||
Layout,
|
||||
Heading,
|
||||
Body,
|
||||
Icon,
|
||||
Popover,
|
||||
notifications,
|
||||
List,
|
||||
ListItem,
|
||||
StatusLight,
|
||||
} from "@budibase/bbui"
|
||||
import UserGroupPicker from "components/settings/UserGroupPicker.svelte"
|
||||
import { createPaginationStore } from "helpers/pagination"
|
||||
import { users, apps, groups } from "stores/portal"
|
||||
import { onMount } from "svelte"
|
||||
import { RoleUtils } from "@budibase/frontend-core"
|
||||
|
||||
export let groupId
|
||||
let popoverAnchor
|
||||
let popover
|
||||
let searchTerm = ""
|
||||
let selectedUsers = []
|
||||
let prevSearch = undefined,
|
||||
search = undefined
|
||||
let pageInfo = createPaginationStore()
|
||||
|
||||
$: page = $pageInfo.page
|
||||
$: fetchUsers(page, search)
|
||||
$: group = $groups.find(x => x._id === groupId)
|
||||
|
||||
async function addAll() {
|
||||
group.users = selectedUsers
|
||||
await groups.actions.save(group)
|
||||
}
|
||||
|
||||
async function selectUser(id) {
|
||||
let selectedUser = selectedUsers.includes(id)
|
||||
if (selectedUser) {
|
||||
selectedUsers = selectedUsers.filter(id => id !== selectedUser)
|
||||
let newUsers = group.users.filter(user => user._id !== id)
|
||||
group.users = newUsers
|
||||
} else {
|
||||
let enrichedUser = $users.data
|
||||
.filter(user => user._id === id)
|
||||
.map(u => {
|
||||
return {
|
||||
_id: u._id,
|
||||
email: u.email,
|
||||
}
|
||||
})[0]
|
||||
selectedUsers = [...selectedUsers, id]
|
||||
group.users.push(enrichedUser)
|
||||
}
|
||||
|
||||
await groups.actions.save(group)
|
||||
|
||||
let user = await users.get(id)
|
||||
|
||||
let userGroups = user.userGroups || []
|
||||
userGroups.push(groupId)
|
||||
await users.save({
|
||||
...user,
|
||||
userGroups,
|
||||
})
|
||||
}
|
||||
$: filtered =
|
||||
$users.data?.filter(x => !group?.users.map(y => y._id).includes(x._id)) ||
|
||||
[]
|
||||
|
||||
$: groupApps = $apps.filter(x => group.apps.includes(x.appId))
|
||||
async function removeUser(id) {
|
||||
let newUsers = group.users.filter(user => user._id !== id)
|
||||
group.users = newUsers
|
||||
let user = await users.get(id)
|
||||
|
||||
await users.save({
|
||||
...user,
|
||||
userGroups: [],
|
||||
})
|
||||
|
||||
await groups.actions.save(group)
|
||||
}
|
||||
|
||||
async function fetchUsers(page, search) {
|
||||
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, search })
|
||||
pageInfo.fetched($users.hasNextPage, $users.nextPage)
|
||||
} catch (error) {
|
||||
notifications.error("Error getting user list")
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
await groups.actions.init()
|
||||
await apps.load()
|
||||
} catch (error) {
|
||||
notifications.error("Error fetching User Group data")
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<Layout noPadding>
|
||||
<div>
|
||||
<ActionButton on:click={() => $goto("../groups")} size="S" 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} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-padding">
|
||||
<Heading>{group?.name}</Heading>
|
||||
</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>
|
||||
|
||||
<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
|
||||
color={RoleUtils.getRoleColour(group.roles[app.appId])}
|
||||
/>
|
||||
<div style="margin-left: var(--spacing-s);">
|
||||
<Body size="XS">{group.roles[app.appId]}</Body>
|
||||
</div>
|
||||
</div>
|
||||
</ListItem>
|
||||
{/each}
|
||||
{:else}
|
||||
<ListItem icon="UserGroup" title="No apps" />
|
||||
{/if}
|
||||
</List>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
.text-padding {
|
||||
margin-left: var(--spacing-l);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,58 @@
|
|||
<script>
|
||||
import {
|
||||
ColorPicker,
|
||||
Body,
|
||||
ModalContent,
|
||||
Input,
|
||||
IconPicker,
|
||||
} from "@budibase/bbui"
|
||||
|
||||
export let group
|
||||
export let saveGroup
|
||||
</script>
|
||||
|
||||
<ModalContent
|
||||
onConfirm={() => saveGroup(group)}
|
||||
size="M"
|
||||
title="Create User Group"
|
||||
confirmText="Save"
|
||||
>
|
||||
<Input bind:value={group.name} label="Team name" />
|
||||
<div class="modal-format">
|
||||
<div class="modal-inner">
|
||||
<Body size="XS">Icon</Body>
|
||||
<div class="modal-spacing">
|
||||
<IconPicker
|
||||
bind:value={group.icon}
|
||||
on:change={e => (group.icon = e.detail)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-inner">
|
||||
<Body size="XS">Color</Body>
|
||||
<div class="modal-spacing">
|
||||
<ColorPicker
|
||||
bind:value={group.color}
|
||||
on:change={e => (group.color = e.detail)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalContent>
|
||||
|
||||
<style>
|
||||
.modal-format {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
.modal-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-spacing {
|
||||
margin-left: var(--spacing-l);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,129 @@
|
|||
<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>
|
|
@ -0,0 +1,3 @@
|
|||
<div style="float: right;">
|
||||
<slot />
|
||||
</div>
|
|
@ -0,0 +1,145 @@
|
|||
<script>
|
||||
import {
|
||||
Layout,
|
||||
Heading,
|
||||
Body,
|
||||
Button,
|
||||
Modal,
|
||||
Tag,
|
||||
Tags,
|
||||
notifications,
|
||||
} from "@budibase/bbui"
|
||||
import { groups, auth } from "stores/portal"
|
||||
import { onMount } from "svelte"
|
||||
import { Constants } from "@budibase/frontend-core"
|
||||
import CreateEditGroupModal from "./_components/CreateEditGroupModal.svelte"
|
||||
import UserGroupsRow from "./_components/UserGroupsRow.svelte"
|
||||
|
||||
$: hasGroupsLicense = $auth.user?.license.features.includes(
|
||||
Constants.Features.USER_GROUPS
|
||||
)
|
||||
|
||||
let modal
|
||||
let group = {
|
||||
name: "",
|
||||
icon: "UserGroup",
|
||||
color: "var(--spectrum-global-color-blue-600)",
|
||||
users: [],
|
||||
apps: [],
|
||||
roles: {},
|
||||
}
|
||||
|
||||
async function deleteGroup(group) {
|
||||
try {
|
||||
groups.actions.delete(group)
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to delete group`)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveGroup(group) {
|
||||
try {
|
||||
await groups.actions.save(group)
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to save group`)
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
if (hasGroupsLicense) {
|
||||
await groups.actions.init()
|
||||
}
|
||||
} catch (error) {
|
||||
notifications.error("Error getting User groups")
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<Layout noPadding>
|
||||
<Layout gap="XS" noPadding>
|
||||
<div style="display: flex;">
|
||||
<Heading size="M">User groups</Heading>
|
||||
{#if !hasGroupsLicense}
|
||||
<Tags>
|
||||
<div class="tags">
|
||||
<div class="tag">
|
||||
<Tag icon="LockClosed">Pro plan</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</Tags>
|
||||
{/if}
|
||||
</div>
|
||||
<Body>Easily assign and manage your users access with User Groups</Body>
|
||||
</Layout>
|
||||
<div class="align-buttons">
|
||||
<Button
|
||||
newStyles
|
||||
icon={hasGroupsLicense ? "UserGroup" : ""}
|
||||
cta={hasGroupsLicense}
|
||||
on:click={hasGroupsLicense
|
||||
? () => modal.show()
|
||||
: window.open("https://budibase.com/pricing/", "_blank")}
|
||||
>{hasGroupsLicense ? "Create user group" : "Upgrade Account"}</Button
|
||||
>
|
||||
{#if !hasGroupsLicense}
|
||||
<Button
|
||||
newStyles
|
||||
secondary
|
||||
on:click={() => {
|
||||
window.open("https://budibase.com/pricing/", "_blank")
|
||||
}}>View Plans</Button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if hasGroupsLicense && $groups.length}
|
||||
<div class="groupTable">
|
||||
{#each $groups as group}
|
||||
<div>
|
||||
<UserGroupsRow {saveGroup} {deleteGroup} {group} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Layout>
|
||||
|
||||
<Modal bind:this={modal}>
|
||||
<CreateEditGroupModal bind:group {saveGroup} />
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.align-buttons {
|
||||
display: flex;
|
||||
column-gap: var(--spacing-xl);
|
||||
}
|
||||
.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: 70px;
|
||||
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>
|
|
@ -2,79 +2,102 @@
|
|||
import { goto } from "@roxi/routify"
|
||||
import {
|
||||
ActionButton,
|
||||
ActionMenu,
|
||||
Avatar,
|
||||
Button,
|
||||
Layout,
|
||||
Heading,
|
||||
Body,
|
||||
Divider,
|
||||
Label,
|
||||
List,
|
||||
ListItem,
|
||||
Icon,
|
||||
Input,
|
||||
MenuItem,
|
||||
Popover,
|
||||
Select,
|
||||
Toggle,
|
||||
Modal,
|
||||
Table,
|
||||
ModalContent,
|
||||
notifications,
|
||||
StatusLight,
|
||||
} from "@budibase/bbui"
|
||||
import { onMount } from "svelte"
|
||||
|
||||
import { fetchData } from "helpers"
|
||||
import { users, auth } from "stores/portal"
|
||||
|
||||
import TagsRenderer from "./_components/RolesTagsTableRenderer.svelte"
|
||||
|
||||
import UpdateRolesModal from "./_components/UpdateRolesModal.svelte"
|
||||
import { users, auth, groups, apps } from "stores/portal"
|
||||
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"
|
||||
|
||||
export let userId
|
||||
let deleteUserModal
|
||||
let editRolesModal
|
||||
|
||||
let deleteModal
|
||||
let resetPasswordModal
|
||||
let popoverAnchor
|
||||
let searchTerm = ""
|
||||
let popover
|
||||
let selectedGroups = []
|
||||
let allAppList = []
|
||||
let user
|
||||
$: fetchUser(userId)
|
||||
$: hasGroupsLicense = $auth.user?.license.features.includes(
|
||||
Constants.Features.USER_GROUPS
|
||||
)
|
||||
|
||||
const roleSchema = {
|
||||
name: { displayName: "App" },
|
||||
role: {},
|
||||
}
|
||||
|
||||
const noRoleSchema = {
|
||||
name: { displayName: "App" },
|
||||
}
|
||||
|
||||
$: defaultRoleId = $userFetch?.data?.builder?.global ? "ADMIN" : ""
|
||||
// Merge the Apps list and the roles response to get something that makes sense for the table
|
||||
$: allAppList = Object.keys($apps?.data).map(id => {
|
||||
const roleId = $userFetch?.data?.roles?.[id] || defaultRoleId
|
||||
const role = $apps?.data?.[id].roles.find(role => role._id === roleId)
|
||||
return {
|
||||
...$apps?.data?.[id],
|
||||
_id: id,
|
||||
role: [role],
|
||||
}
|
||||
$: 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())
|
||||
)
|
||||
$: userGroups = $groups.filter(x => {
|
||||
return x.users?.find(y => {
|
||||
return y._id === userId
|
||||
})
|
||||
})
|
||||
|
||||
$: appList = allAppList.filter(app => !!app.role[0])
|
||||
$: noRoleAppList = allAppList
|
||||
.filter(app => !app.role[0])
|
||||
.map(app => {
|
||||
delete app.role
|
||||
return app
|
||||
})
|
||||
|
||||
let selectedApp
|
||||
$: globalRole = $userFetch?.data?.admin?.global
|
||||
? "admin"
|
||||
: $userFetch?.data?.builder?.global
|
||||
? "developer"
|
||||
: "appUser"
|
||||
|
||||
const userFetch = fetchData(`/api/global/users/${userId}`)
|
||||
const apps = fetchData(`/api/global/roles`)
|
||||
|
||||
async function deleteUser() {
|
||||
try {
|
||||
await users.delete(userId)
|
||||
notifications.success(`User ${$userFetch?.data?.email} deleted.`)
|
||||
$goto("./")
|
||||
} catch (error) {
|
||||
notifications.error("Error deleting user")
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
let toggleDisabled = false
|
||||
|
||||
async function updateUserFirstName(evt) {
|
||||
try {
|
||||
await users.save({ ...$userFetch?.data, firstName: evt.target.value })
|
||||
|
@ -84,6 +107,13 @@
|
|||
}
|
||||
}
|
||||
|
||||
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 })
|
||||
|
@ -93,61 +123,95 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function toggleFlag(flagName, detail) {
|
||||
toggleDisabled = true
|
||||
async function updateUserRole({ detail }) {
|
||||
if (detail === "developer") {
|
||||
toggleFlags({ admin: { global: false }, builder: { global: true } })
|
||||
} else if (detail === "admin") {
|
||||
toggleFlags({ admin: { global: true }, builder: { global: false } })
|
||||
} else if (detail === "appUser") {
|
||||
toggleFlags({ admin: { global: false }, builder: { global: false } })
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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, [flagName]: { global: detail } })
|
||||
await users.save({ ...$userFetch?.data, ...detail })
|
||||
await userFetch.refresh()
|
||||
} catch (error) {
|
||||
notifications.error("Error updating user")
|
||||
}
|
||||
toggleDisabled = false
|
||||
}
|
||||
|
||||
async function toggleBuilderAccess({ detail }) {
|
||||
return toggleFlag("builder", detail)
|
||||
}
|
||||
|
||||
async function toggleAdminAccess({ detail }) {
|
||||
return toggleFlag("admin", detail)
|
||||
}
|
||||
|
||||
async function openUpdateRolesModal({ detail }) {
|
||||
selectedApp = detail
|
||||
editRolesModal.show()
|
||||
}
|
||||
function addAll() {}
|
||||
onMount(async () => {
|
||||
try {
|
||||
await groups.actions.init()
|
||||
await apps.load()
|
||||
} catch (error) {
|
||||
notifications.error("Error getting User groups")
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<Layout noPadding>
|
||||
<Layout gap="L" noPadding>
|
||||
<Layout gap="XS" noPadding>
|
||||
<div>
|
||||
<ActionButton
|
||||
on:click={() => $goto("./")}
|
||||
quiet
|
||||
size="S"
|
||||
icon="BackAndroid"
|
||||
>
|
||||
Back to users
|
||||
<ActionButton on:click={() => $goto("./")} size="S" icon="ArrowLeft">
|
||||
Back
|
||||
</ActionButton>
|
||||
</div>
|
||||
<Heading>User: {$userFetch?.data?.email}</Heading>
|
||||
<Body>
|
||||
Change user settings and update their app roles. Also contains the ability
|
||||
to delete the user as well as force reset their password.
|
||||
</Body>
|
||||
</Layout>
|
||||
<Divider size="S" />
|
||||
<Layout gap="XS" noPadding>
|
||||
<div class="title">
|
||||
<div>
|
||||
<div style="display: flex;">
|
||||
<Avatar size="XXL" initials="PC" />
|
||||
<div class="subtitle">
|
||||
<Heading size="S"
|
||||
>{$userFetch?.data?.firstName +
|
||||
" " +
|
||||
$userFetch?.data?.lastName}</Heading
|
||||
>
|
||||
<Body size="XS">{$userFetch?.data?.email}</Body>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<ActionMenu align="right">
|
||||
<span slot="control">
|
||||
<Icon hoverable name="More" />
|
||||
</span>
|
||||
<MenuItem on:click={resetPasswordModal.show} icon="Refresh"
|
||||
>Force Password Reset</MenuItem
|
||||
>
|
||||
<MenuItem on:click={deleteModal.show} icon="Delete">Delete</MenuItem>
|
||||
</ActionMenu>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
<Layout gap="S" noPadding>
|
||||
<Heading size="S">General</Heading>
|
||||
<div class="fields">
|
||||
<div class="field">
|
||||
<Label size="L">Email</Label>
|
||||
<Input disabled thin value={$userFetch?.data?.email} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<Label size="L">Group(s)</Label>
|
||||
<Select disabled options={["All users"]} value="All users" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<Label size="L">First name</Label>
|
||||
<Input
|
||||
|
@ -167,93 +231,104 @@
|
|||
<!-- don't let a user remove the privileges that let them be here -->
|
||||
{#if userId !== $auth.user._id}
|
||||
<div class="field">
|
||||
<Label size="L">Development access</Label>
|
||||
<Toggle
|
||||
text=""
|
||||
value={$userFetch?.data?.builder?.global}
|
||||
on:change={toggleBuilderAccess}
|
||||
disabled={toggleDisabled}
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<Label size="L">Administration access</Label>
|
||||
<Toggle
|
||||
text=""
|
||||
value={$userFetch?.data?.admin?.global}
|
||||
on:change={toggleAdminAccess}
|
||||
disabled={toggleDisabled}
|
||||
<Label size="L">Role</Label>
|
||||
<Select
|
||||
value={globalRole}
|
||||
options={Constants.BbRoles}
|
||||
on:change={updateUserRole}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="regenerate">
|
||||
<ActionButton
|
||||
size="S"
|
||||
icon="Refresh"
|
||||
quiet
|
||||
on:click={resetPasswordModal.show}>Force password reset</ActionButton
|
||||
>
|
||||
</Layout>
|
||||
|
||||
{#if hasGroupsLicense}
|
||||
<!-- User groups -->
|
||||
<Layout gap="XS" noPadding>
|
||||
<div class="tableTitle">
|
||||
<div>
|
||||
<Heading size="XS">User groups</Heading>
|
||||
<Body size="S">Add or remove this user from user groups</Body>
|
||||
</div>
|
||||
<div bind:this={popoverAnchor}>
|
||||
<Button on:click={popover.show()} icon="UserGroup" cta
|
||||
>Add User Group</Button
|
||||
>
|
||||
</div>
|
||||
<Popover align="right" bind:this={popover} anchor={popoverAnchor}>
|
||||
<UserGroupPicker
|
||||
key={"name"}
|
||||
title={"Group"}
|
||||
bind:searchTerm
|
||||
bind:selected={selectedGroups}
|
||||
bind:filtered={filteredGroups}
|
||||
{addAll}
|
||||
select={addGroup}
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<List>
|
||||
{#if userGroups.length}
|
||||
{#each userGroups as group}
|
||||
<ListItem
|
||||
title={group.name}
|
||||
icon={group.icon}
|
||||
iconBackground={group.color}
|
||||
><Icon
|
||||
on:click={removeGroup(group._id)}
|
||||
hoverable
|
||||
size="L"
|
||||
name="Close"
|
||||
/></ListItem
|
||||
>
|
||||
{/each}
|
||||
{:else}
|
||||
<ListItem icon="UserGroup" title="No groups" />
|
||||
{/if}
|
||||
</List>
|
||||
</Layout>
|
||||
{/if}
|
||||
<!-- User Apps -->
|
||||
<Layout gap="S" noPadding>
|
||||
<div class="appsTitle">
|
||||
<Heading weight="light" size="XS">Apps</Heading>
|
||||
<div style="margin-top: var(--spacing-xs)">
|
||||
<Body size="S">Manage apps that this user has been assigned to</Body>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<List>
|
||||
{#if allAppList.length}
|
||||
{#each allAppList as app}
|
||||
<div class="pointer" on:click={$goto(`../../overview/${app.devId}`)}>
|
||||
<ListItem
|
||||
title={app.name}
|
||||
iconBackground={app?.icon?.color || ""}
|
||||
icon={app?.icon?.name || "Apps"}
|
||||
>
|
||||
<div class="title ">
|
||||
<StatusLight
|
||||
color={RoleUtils.getRoleColour(getHighestRole(app.roles))}
|
||||
/>
|
||||
<div style="margin-left: var(--spacing-s);">
|
||||
<Body size="XS"
|
||||
>{Constants.Roles[getHighestRole(app.roles)]}</Body
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</ListItem>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<ListItem icon="Apps" title="No apps" />
|
||||
{/if}
|
||||
</List>
|
||||
</Layout>
|
||||
<Divider size="S" />
|
||||
<Layout gap="S" noPadding>
|
||||
<Heading size="S">Configure roles</Heading>
|
||||
<Body>Specify a role to grant access to an app.</Body>
|
||||
<Table
|
||||
on:click={openUpdateRolesModal}
|
||||
schema={roleSchema}
|
||||
data={appList}
|
||||
allowEditColumns={false}
|
||||
allowEditRows={false}
|
||||
allowSelectRows={false}
|
||||
customRenderers={[{ column: "role", component: TagsRenderer }]}
|
||||
/>
|
||||
</Layout>
|
||||
<Layout gap="S" noPadding>
|
||||
<Heading size="XS">No Access</Heading>
|
||||
<Body
|
||||
>Apps do not appear in the users portal. Public pages may still be viewed
|
||||
if visited directly.</Body
|
||||
>
|
||||
<Table
|
||||
on:click={openUpdateRolesModal}
|
||||
schema={noRoleSchema}
|
||||
data={noRoleAppList}
|
||||
allowEditColumns={false}
|
||||
allowEditRows={false}
|
||||
allowSelectRows={false}
|
||||
/>
|
||||
</Layout>
|
||||
<Divider size="S" />
|
||||
<Layout gap="XS" noPadding>
|
||||
<Heading size="S">Delete user</Heading>
|
||||
<Body>Deleting a user completely removes them from your account.</Body>
|
||||
</Layout>
|
||||
<div class="delete-button">
|
||||
<Button warning on:click={deleteUserModal.show}>Delete user</Button>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<Modal bind:this={deleteUserModal}>
|
||||
<ModalContent
|
||||
warning
|
||||
onConfirm={deleteUser}
|
||||
title="Delete User"
|
||||
confirmText="Delete user"
|
||||
cancelText="Cancel"
|
||||
showCloseIcon={false}
|
||||
>
|
||||
<Body>
|
||||
Are you sure you want to delete <strong>{$userFetch?.data?.email}</strong>
|
||||
</Body>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<Modal bind:this={editRolesModal}>
|
||||
<UpdateRolesModal
|
||||
app={selectedApp}
|
||||
user={$userFetch.data}
|
||||
on:update={userFetch.refresh}
|
||||
/>
|
||||
<Modal bind:this={deleteModal}>
|
||||
<DeleteUserModal user={$userFetch.data} />
|
||||
</Modal>
|
||||
<Modal bind:this={resetPasswordModal}>
|
||||
<ForceResetPasswordModal
|
||||
|
@ -263,6 +338,9 @@
|
|||
</Modal>
|
||||
|
||||
<style>
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
.fields {
|
||||
display: grid;
|
||||
grid-gap: var(--spacing-m);
|
||||
|
@ -272,9 +350,26 @@
|
|||
grid-template-columns: 32% 1fr;
|
||||
align-items: center;
|
||||
}
|
||||
.regenerate {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.tableTitle {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--spacing-m);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
padding: 0 0 0 var(--spacing-m);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.appsTitle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,113 +1,86 @@
|
|||
<script>
|
||||
import {
|
||||
Body,
|
||||
Input,
|
||||
Label,
|
||||
ActionButton,
|
||||
ModalContent,
|
||||
notifications,
|
||||
Select,
|
||||
Toggle,
|
||||
Multiselect,
|
||||
InputDropdown,
|
||||
Layout,
|
||||
} from "@budibase/bbui"
|
||||
import { createValidationStore, emailValidator } from "helpers/validation"
|
||||
import { users } from "stores/portal"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
import { groups, auth } from "stores/portal"
|
||||
import { Constants } from "@budibase/frontend-core"
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
export let showOnboardingTypeModal
|
||||
const password = Math.random().toString(36).substring(2, 22)
|
||||
const options = ["Email onboarding", "Basic onboarding"]
|
||||
const [email, error, touched] = createValidationStore("", emailValidator)
|
||||
let disabled
|
||||
let builder
|
||||
let admin
|
||||
let selected = "Email onboarding"
|
||||
let userGroups = []
|
||||
|
||||
$: basic = selected === "Basic onboarding"
|
||||
$: hasGroupsLicense = $auth.user?.license.features.includes(
|
||||
Constants.Features.USER_GROUPS
|
||||
)
|
||||
|
||||
function addUser() {
|
||||
if (basic) {
|
||||
createUser()
|
||||
} else {
|
||||
createUserFlow()
|
||||
}
|
||||
}
|
||||
|
||||
async function createUser() {
|
||||
try {
|
||||
await users.create({
|
||||
email: $email,
|
||||
password,
|
||||
builder,
|
||||
admin,
|
||||
$: userData = [
|
||||
{
|
||||
email: "",
|
||||
role: "appUser",
|
||||
password,
|
||||
forceResetPassword: true,
|
||||
},
|
||||
]
|
||||
function addNewInput() {
|
||||
userData = [
|
||||
...userData,
|
||||
{
|
||||
email: "",
|
||||
role: "appUser",
|
||||
password: Math.random().toString(36).substring(2, 22),
|
||||
forceResetPassword: true,
|
||||
})
|
||||
notifications.success("Successfully created user")
|
||||
dispatch("created")
|
||||
} catch (error) {
|
||||
notifications.error("Error creating user")
|
||||
}
|
||||
}
|
||||
|
||||
async function createUserFlow() {
|
||||
try {
|
||||
const res = await users.invite({ email: $email, builder, admin })
|
||||
notifications.success(res.message)
|
||||
} catch (error) {
|
||||
notifications.error("Error inviting user")
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<ModalContent
|
||||
onConfirm={addUser}
|
||||
onConfirm={async () =>
|
||||
showOnboardingTypeModal({ users: userData, groups: userGroups })}
|
||||
size="M"
|
||||
title="Add new user"
|
||||
confirmText="Add user"
|
||||
confirmDisabled={disabled}
|
||||
cancelText="Cancel"
|
||||
disabled={$error}
|
||||
showCloseIcon={false}
|
||||
>
|
||||
<Body size="S">
|
||||
If you have SMTP configured and an email for the new user, you can use the
|
||||
automated email onboarding flow. Otherwise, use our basic onboarding process
|
||||
with autogenerated passwords.
|
||||
</Body>
|
||||
<Select
|
||||
placeholder={null}
|
||||
bind:value={selected}
|
||||
{options}
|
||||
label="Add new user via:"
|
||||
/>
|
||||
<Layout noPadding gap="XS">
|
||||
<Label>Email Address</Label>
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
label="Email"
|
||||
bind:value={$email}
|
||||
error={$touched && $error}
|
||||
placeholder="john@doe.com"
|
||||
/>
|
||||
{#each userData as input, index}
|
||||
<InputDropdown
|
||||
inputType="email"
|
||||
bind:inputValue={input.email}
|
||||
bind:dropdownValue={input.role}
|
||||
options={Constants.BbRoles}
|
||||
error={input.error}
|
||||
/>
|
||||
{/each}
|
||||
<div>
|
||||
<ActionButton on:click={addNewInput} icon="Add">Add email</ActionButton>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
{#if basic}
|
||||
<Input disabled label="Password" value={password} />
|
||||
{#if hasGroupsLicense}
|
||||
<Multiselect
|
||||
bind:value={userGroups}
|
||||
placeholder="Select User Groups"
|
||||
label="User Groups"
|
||||
options={$groups}
|
||||
getOptionLabel={option => option.name}
|
||||
getOptionValue={option => option._id}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<div class="toggle">
|
||||
<Label size="L">Development access</Label>
|
||||
<Toggle text="" bind:value={builder} />
|
||||
</div>
|
||||
<div class="toggle">
|
||||
<Label size="L">Administration access</Label>
|
||||
<Toggle text="" bind:value={admin} />
|
||||
</div>
|
||||
</div>
|
||||
</ModalContent>
|
||||
|
||||
<style>
|
||||
.toggle {
|
||||
display: grid;
|
||||
grid-template-columns: 78% 1fr;
|
||||
align-items: center;
|
||||
width: 50%;
|
||||
:global(.spectrum-Picker) {
|
||||
border-top-left-radius: 0px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
<script>
|
||||
import { Icon } from "@budibase/bbui"
|
||||
export let value
|
||||
</script>
|
||||
|
||||
<div class="align">
|
||||
<div class="spacing">
|
||||
<Icon name="WebPage" />
|
||||
</div>
|
||||
{parseInt(value?.length) || 0}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.align {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.spacing {
|
||||
margin-right: var(--spacing-m);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,31 @@
|
|||
<script>
|
||||
import { goto } from "@roxi/routify"
|
||||
import { Body, ModalContent, notifications } from "@budibase/bbui"
|
||||
|
||||
import { users } from "stores/portal"
|
||||
|
||||
export let user
|
||||
|
||||
async function deleteUser() {
|
||||
try {
|
||||
await users.delete(user._id)
|
||||
notifications.success(`User ${user?.email} deleted.`)
|
||||
$goto("./")
|
||||
} catch (error) {
|
||||
notifications.error("Error deleting user")
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<ModalContent
|
||||
warning
|
||||
onConfirm={deleteUser}
|
||||
title="Delete User"
|
||||
confirmText="Delete user"
|
||||
cancelText="Cancel"
|
||||
showCloseIcon={false}
|
||||
>
|
||||
<Body>
|
||||
Are you sure you want to delete <strong>{user?.email}</strong>
|
||||
</Body>
|
||||
</ModalContent>
|
|
@ -0,0 +1,36 @@
|
|||
<script>
|
||||
import { Icon, Body } from "@budibase/bbui"
|
||||
export let value
|
||||
</script>
|
||||
|
||||
<div class="align">
|
||||
<div class="spacing">
|
||||
<Icon name="UserGroup" />
|
||||
</div>
|
||||
{#if value?.length === 0}
|
||||
<div class="opacity">0</div>
|
||||
{:else if value?.length === 1}
|
||||
<div class="opacity">
|
||||
<Body size="S">{value[0]?.name}</Body>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="opacity">
|
||||
{parseInt(value?.length) || 0} groups
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.align {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.opacity {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.spacing {
|
||||
margin-right: var(--spacing-m);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,157 @@
|
|||
<script>
|
||||
import {
|
||||
Body,
|
||||
ModalContent,
|
||||
RadioGroup,
|
||||
Multiselect,
|
||||
notifications,
|
||||
} from "@budibase/bbui"
|
||||
import { groups, auth, admin } from "stores/portal"
|
||||
import { emailValidator } from "../../../../../../helpers/validation"
|
||||
import { Constants } from "@budibase/frontend-core"
|
||||
|
||||
const BYTES_IN_MB = 1000000
|
||||
const FILE_SIZE_LIMIT = BYTES_IN_MB * 5
|
||||
const MAX_USERS_UPLOAD_LIMIT = 1000
|
||||
export let createUsersFromCsv
|
||||
|
||||
let files = []
|
||||
let csvString = undefined
|
||||
let userEmails = []
|
||||
let userGroups = []
|
||||
let usersRole = null
|
||||
|
||||
$: invalidEmails = []
|
||||
$: hasGroupsLicense = $auth.user?.license.features.includes(
|
||||
Constants.Features.USER_GROUPS
|
||||
)
|
||||
|
||||
const validEmails = userEmails => {
|
||||
if ($admin.cloud && userEmails.length > MAX_USERS_UPLOAD_LIMIT) {
|
||||
notifications.error(
|
||||
`Max limit for upload is 1000 users. Please reduce file size and try again.`
|
||||
)
|
||||
return false
|
||||
}
|
||||
for (const email of userEmails) {
|
||||
if (emailValidator(email) !== true) invalidEmails.push(email)
|
||||
}
|
||||
|
||||
if (!invalidEmails.length) return true
|
||||
|
||||
notifications.error(
|
||||
`Error, please check the following email${
|
||||
invalidEmails.length > 1 ? "s" : ""
|
||||
}: ${invalidEmails.join(", ")}`
|
||||
)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
async function handleFile(evt) {
|
||||
const fileArray = Array.from(evt.target.files)
|
||||
if (fileArray.some(file => file.size >= FILE_SIZE_LIMIT)) {
|
||||
notifications.error(
|
||||
`Files cannot exceed ${
|
||||
FILE_SIZE_LIMIT / BYTES_IN_MB
|
||||
}MB. Please try again with smaller files.`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Read CSV as plain text to upload alongside schema
|
||||
let reader = new FileReader()
|
||||
reader.addEventListener("load", function (e) {
|
||||
csvString = e.target.result
|
||||
files = fileArray
|
||||
|
||||
userEmails = csvString.split("\n")
|
||||
})
|
||||
reader.readAsText(fileArray[0])
|
||||
}
|
||||
</script>
|
||||
|
||||
<ModalContent
|
||||
size="M"
|
||||
title="Import users"
|
||||
confirmText="Done"
|
||||
showCancelButton={false}
|
||||
cancelText="Cancel"
|
||||
showCloseIcon={false}
|
||||
onConfirm={() => createUsersFromCsv({ userEmails, usersRole, userGroups })}
|
||||
disabled={!userEmails.length || !validEmails(userEmails) || !usersRole}
|
||||
>
|
||||
<Body size="S">Import your users email addrresses from a CSV</Body>
|
||||
|
||||
<div class="dropzone">
|
||||
<input id="file-upload" accept=".csv" type="file" on:change={handleFile} />
|
||||
<label for="file-upload" class:uploaded={files[0]}>
|
||||
{#if files[0]}{files[0].name}{:else}Upload{/if}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<RadioGroup
|
||||
bind:value={usersRole}
|
||||
options={Constants.BuilderRoleDescriptions}
|
||||
/>
|
||||
|
||||
{#if hasGroupsLicense}
|
||||
<Multiselect
|
||||
bind:value={userGroups}
|
||||
placeholder="Select User Groups"
|
||||
label="User Groups"
|
||||
options={$groups}
|
||||
getOptionLabel={option => option.name}
|
||||
getOptionValue={option => option._id}
|
||||
/>
|
||||
{/if}
|
||||
</ModalContent>
|
||||
|
||||
<style>
|
||||
:global(.spectrum-Picker) {
|
||||
border-top-left-radius: 0px;
|
||||
}
|
||||
|
||||
.dropzone {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
border-radius: 10px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.uploaded {
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
label {
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
border-radius: var(--border-radius-s);
|
||||
color: var(--ink);
|
||||
padding: var(--spacing-m) var(--spacing-l);
|
||||
transition: all 0.2s ease 0s;
|
||||
display: inline-flex;
|
||||
text-rendering: optimizeLegibility;
|
||||
min-width: auto;
|
||||
outline: none;
|
||||
font-feature-settings: "case" 1, "rlig" 1, "calt" 0;
|
||||
-webkit-box-align: center;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
background-color: var(--grey-2);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: normal;
|
||||
border: var(--border-transparent);
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,38 @@
|
|||
<script>
|
||||
import { Avatar } from "@budibase/bbui"
|
||||
|
||||
export let value
|
||||
</script>
|
||||
|
||||
<div class="align">
|
||||
{#if value}
|
||||
<div class="spacing">
|
||||
<Avatar
|
||||
size="L"
|
||||
initials={value
|
||||
.split(" ")
|
||||
.map(x => x[0])
|
||||
.join("")}
|
||||
/>
|
||||
</div>
|
||||
{value}
|
||||
{:else}
|
||||
<div class="text">Not Available</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.align {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.spacing {
|
||||
margin-right: var(--spacing-m);
|
||||
}
|
||||
|
||||
.text {
|
||||
opacity: 0.8;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,108 @@
|
|||
<script>
|
||||
import { ModalContent, Body, Layout, Icon } from "@budibase/bbui"
|
||||
|
||||
export let chooseCreationType
|
||||
let emailOnboardingKey = "emailOnboarding"
|
||||
let basicOnboaridngKey = "basicOnboarding"
|
||||
|
||||
let selectedOnboardingType
|
||||
</script>
|
||||
|
||||
<ModalContent
|
||||
size="M"
|
||||
title="Choose your onboarding"
|
||||
confirmText="Done"
|
||||
cancelText="Cancel"
|
||||
showCloseIcon={false}
|
||||
onConfirm={() => chooseCreationType(selectedOnboardingType)}
|
||||
disabled={!selectedOnboardingType}
|
||||
>
|
||||
<Layout noPadding gap="S">
|
||||
<div
|
||||
class="onboarding-type item"
|
||||
class:selected={selectedOnboardingType == emailOnboardingKey}
|
||||
on:click={() => {
|
||||
selectedOnboardingType = emailOnboardingKey
|
||||
}}
|
||||
>
|
||||
<div class="content onboarding-type-wrap">
|
||||
<Icon name="WebPage" />
|
||||
<div class="onboarding-type-text">
|
||||
<Body size="S">Send email invites</Body>
|
||||
</div>
|
||||
</div>
|
||||
<div style="color: var(--spectrum-global-color-green-600); float: right">
|
||||
{#if selectedOnboardingType == emailOnboardingKey}
|
||||
<div class="checkmark-spacing">
|
||||
<Icon size="S" name="CheckmarkCircle" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="onboarding-type item"
|
||||
class:selected={selectedOnboardingType == basicOnboaridngKey}
|
||||
on:click={() => {
|
||||
selectedOnboardingType = basicOnboaridngKey
|
||||
}}
|
||||
>
|
||||
<div class="content onboarding-type-wrap">
|
||||
<Icon name="Key" />
|
||||
<div class="onboarding-type-text">
|
||||
<Body size="S">Generate passwords for each user</Body>
|
||||
</div>
|
||||
</div>
|
||||
<div style="color: var(--spectrum-global-color-green-600); float: right">
|
||||
{#if selectedOnboardingType == basicOnboaridngKey}
|
||||
<div class="checkmark-spacing">
|
||||
<Icon size="S" name="CheckmarkCircle" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
</ModalContent>
|
||||
|
||||
<style>
|
||||
.onboarding-type.item {
|
||||
padding: var(--spectrum-alias-item-padding-xl);
|
||||
}
|
||||
.onboarding-type-wrap {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
.checkmark-spacing {
|
||||
margin-right: var(--spacing-m);
|
||||
}
|
||||
.content {
|
||||
letter-spacing: 0px;
|
||||
}
|
||||
.item {
|
||||
cursor: pointer;
|
||||
grid-gap: var(--spectrum-alias-grid-margin-xsmall);
|
||||
padding: var(--spectrum-alias-item-padding-s);
|
||||
background: var(--spectrum-alias-background-color-primary);
|
||||
transition: 0.3s all;
|
||||
border: 1px solid var(--spectrum-global-color-gray-300);
|
||||
border-radius: 4px;
|
||||
border-width: 1px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.item:hover,
|
||||
.selected {
|
||||
background: var(--spectrum-alias-background-color-tertiary);
|
||||
}
|
||||
.onboarding-type-wrap .onboarding-type-text {
|
||||
padding-left: var(--spectrum-alias-item-padding-xl);
|
||||
}
|
||||
.onboarding-type-wrap :global(.spectrum-Icon) {
|
||||
min-width: var(--spectrum-icon-size-m);
|
||||
}
|
||||
.onboarding-type-wrap :global(.spectrum-Heading) {
|
||||
padding-bottom: var(--spectrum-alias-item-padding-s);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,15 @@
|
|||
<script>
|
||||
import { InternalRenderer } from "@budibase/bbui"
|
||||
|
||||
export let value
|
||||
</script>
|
||||
|
||||
<div style="display: flex; ">
|
||||
{value}
|
||||
<div style="margin-left: 1.5rem;">
|
||||
<InternalRenderer {value} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
</style>
|
|
@ -0,0 +1,94 @@
|
|||
<script>
|
||||
import { Body, ModalContent, Table, Icon } from "@budibase/bbui"
|
||||
import PasswordCopyRenderer from "./PasswordCopyRenderer.svelte"
|
||||
import { parseToCsv } from "helpers/data/utils"
|
||||
|
||||
export let userData
|
||||
|
||||
$: mappedData = userData.map(user => {
|
||||
return {
|
||||
email: user.email,
|
||||
password: user.password,
|
||||
}
|
||||
})
|
||||
|
||||
const schema = {
|
||||
email: {},
|
||||
password: {},
|
||||
}
|
||||
|
||||
const downloadCsvFile = () => {
|
||||
const fileName = "passwords.csv"
|
||||
const content = parseToCsv(["email", "password"], mappedData)
|
||||
|
||||
download(fileName, content)
|
||||
}
|
||||
|
||||
const download = (filename, text) => {
|
||||
const element = document.createElement("a")
|
||||
element.setAttribute(
|
||||
"href",
|
||||
"data:text/csv;charset=utf-8," + encodeURIComponent(text)
|
||||
)
|
||||
element.setAttribute("download", filename)
|
||||
|
||||
element.style.display = "none"
|
||||
document.body.appendChild(element)
|
||||
|
||||
element.click()
|
||||
|
||||
document.body.removeChild(element)
|
||||
}
|
||||
</script>
|
||||
|
||||
<ModalContent
|
||||
size="S"
|
||||
title="Accounts created!"
|
||||
confirmText="Done"
|
||||
showCancelButton={false}
|
||||
cancelText="Cancel"
|
||||
showCloseIcon={false}
|
||||
>
|
||||
<Body size="XS"
|
||||
>All your new users can be accessed through the autogenerated passwords.
|
||||
Make not of these passwords or download the csv</Body
|
||||
>
|
||||
|
||||
<div class="container" on:click={downloadCsvFile}>
|
||||
<div class="inner">
|
||||
<Icon name="Download" />
|
||||
|
||||
<div style="margin-left: var(--spacing-m)">
|
||||
<Body size="XS">Passwords CSV</Body>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
{schema}
|
||||
data={mappedData}
|
||||
allowEditColumns={false}
|
||||
allowEditRows={false}
|
||||
allowSelectRows={false}
|
||||
customRenderers={[{ column: "password", component: PasswordCopyRenderer }]}
|
||||
/>
|
||||
</ModalContent>
|
||||
|
||||
<style>
|
||||
.inner {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
:global(.spectrum-Picker) {
|
||||
border-top-left-radius: 0px;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
height: var(--spectrum-alias-item-height-l);
|
||||
background: #009562;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,16 @@
|
|||
<script>
|
||||
import { users } from "stores/portal"
|
||||
import { Constants } from "@budibase/frontend-core"
|
||||
|
||||
export let row
|
||||
$: value =
|
||||
Constants.BbRoles.find(x => x.value === users.getUserRole(row))?.label ||
|
||||
"Not Available"
|
||||
</script>
|
||||
|
||||
<div on:click|stopPropagation>
|
||||
{value}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
</style>
|
|
@ -1,52 +1,232 @@
|
|||
<script>
|
||||
import { goto } from "@roxi/routify"
|
||||
import {
|
||||
Heading,
|
||||
Body,
|
||||
Divider,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Search,
|
||||
Table,
|
||||
Label,
|
||||
Layout,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Icon,
|
||||
notifications,
|
||||
Pagination,
|
||||
Search,
|
||||
Label,
|
||||
} from "@budibase/bbui"
|
||||
import TagsRenderer from "./_components/TagsTableRenderer.svelte"
|
||||
import AddUserModal from "./_components/AddUserModal.svelte"
|
||||
import { users } from "stores/portal"
|
||||
import { users, groups, auth } from "stores/portal"
|
||||
import { onMount } from "svelte"
|
||||
import DeleteRowsButton from "components/backend/DataTable/buttons/DeleteRowsButton.svelte"
|
||||
import GroupsTableRenderer from "./_components/GroupsTableRenderer.svelte"
|
||||
import AppsTableRenderer from "./_components/AppsTableRenderer.svelte"
|
||||
import NameTableRenderer from "./_components/NameTableRenderer.svelte"
|
||||
import RoleTableRenderer from "./_components/RoleTableRenderer.svelte"
|
||||
import { goto } from "@roxi/routify"
|
||||
import OnboardingTypeModal from "./_components/OnboardingTypeModal.svelte"
|
||||
import PasswordModal from "./_components/PasswordModal.svelte"
|
||||
import ImportUsersModal from "./_components/ImportUsersModal.svelte"
|
||||
import { createPaginationStore } from "helpers/pagination"
|
||||
import { Constants } from "@budibase/frontend-core"
|
||||
|
||||
const schema = {
|
||||
email: {},
|
||||
developmentAccess: { displayName: "Development Access", type: "boolean" },
|
||||
adminAccess: { displayName: "Admin Access", type: "boolean" },
|
||||
group: {},
|
||||
}
|
||||
const accessTypes = [
|
||||
{
|
||||
icon: "User",
|
||||
description: "App user - Only has access to published apps",
|
||||
},
|
||||
{
|
||||
icon: "Hammer",
|
||||
description: "Developer - Access to the app builder",
|
||||
},
|
||||
{
|
||||
icon: "Draw",
|
||||
description: "Admin - Full access",
|
||||
},
|
||||
]
|
||||
|
||||
//let email
|
||||
let enrichedUsers = []
|
||||
let createUserModal,
|
||||
inviteConfirmationModal,
|
||||
onboardingTypeModal,
|
||||
passwordModal,
|
||||
importUsersModal
|
||||
|
||||
let pageInfo = createPaginationStore()
|
||||
let prevSearch = undefined,
|
||||
search = undefined
|
||||
let prevEmail = undefined,
|
||||
searchEmail = undefined
|
||||
|
||||
let selectedRows = []
|
||||
let customRenderers = [
|
||||
{ column: "userGroups", component: GroupsTableRenderer },
|
||||
{ column: "apps", component: AppsTableRenderer },
|
||||
{ column: "name", component: NameTableRenderer },
|
||||
{ column: "role", component: RoleTableRenderer },
|
||||
]
|
||||
|
||||
$: hasGroupsLicense = $auth.user?.license.features.includes(
|
||||
Constants.Features.USER_GROUPS
|
||||
)
|
||||
|
||||
$: schema = {
|
||||
name: {},
|
||||
email: {},
|
||||
role: {
|
||||
noPropagation: true,
|
||||
sortable: false,
|
||||
},
|
||||
...(hasGroupsLicense && {
|
||||
userGroups: { sortable: false, displayName: "User groups" },
|
||||
}),
|
||||
apps: { width: "120px" },
|
||||
settings: {
|
||||
sortable: false,
|
||||
width: "60px",
|
||||
displayName: "",
|
||||
align: "Right",
|
||||
},
|
||||
}
|
||||
|
||||
$: userData = []
|
||||
|
||||
$: page = $pageInfo.page
|
||||
$: fetchUsers(page, search)
|
||||
$: fetchUsers(page, searchEmail)
|
||||
$: {
|
||||
enrichedUsers = $users.data?.map(user => {
|
||||
let userGroups = []
|
||||
$groups.forEach(group => {
|
||||
if (group.users) {
|
||||
group.users?.forEach(y => {
|
||||
if (y._id === user._id) {
|
||||
userGroups.push(group)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
return {
|
||||
...user,
|
||||
name: user.firstName ? user.firstName + " " + user.lastName : "",
|
||||
userGroups,
|
||||
apps: [...new Set(Object.keys(user.roles))],
|
||||
}
|
||||
})
|
||||
}
|
||||
const showOnboardingTypeModal = async addUsersData => {
|
||||
userData = await removingDuplicities(addUsersData)
|
||||
if (!userData?.users?.length) return
|
||||
|
||||
let createUserModal
|
||||
onboardingTypeModal.show()
|
||||
}
|
||||
|
||||
async function fetchUsers(page, search) {
|
||||
async function createUserFlow() {
|
||||
let emails = userData?.users?.map(x => x.email) || []
|
||||
try {
|
||||
const res = await users.invite({
|
||||
emails: emails,
|
||||
builder: false,
|
||||
admin: false,
|
||||
})
|
||||
notifications.success(res.message)
|
||||
inviteConfirmationModal.show()
|
||||
} catch (error) {
|
||||
notifications.error("Error inviting user")
|
||||
}
|
||||
}
|
||||
|
||||
const removingDuplicities = async userData => {
|
||||
const currentUserEmails = (await users.fetch())?.map(x => x.email) || []
|
||||
const newUsers = []
|
||||
|
||||
for (const user of userData?.users) {
|
||||
const { email } = user
|
||||
|
||||
if (
|
||||
newUsers.find(x => x.email === email) ||
|
||||
currentUserEmails.includes(email)
|
||||
)
|
||||
continue
|
||||
|
||||
newUsers.push(user)
|
||||
}
|
||||
|
||||
if (!newUsers.length)
|
||||
notifications.info("Duplicated! There is no new users to add.")
|
||||
return { ...userData, users: newUsers }
|
||||
}
|
||||
|
||||
const createUsersFromCsv = async userCsvData => {
|
||||
const { userEmails, usersRole, userGroups: groups } = userCsvData
|
||||
|
||||
const users = []
|
||||
for (const email of userEmails) {
|
||||
const newUser = {
|
||||
email: email,
|
||||
role: usersRole,
|
||||
password: Math.random().toString(36).substring(2, 22),
|
||||
forceResetPassword: true,
|
||||
}
|
||||
|
||||
users.push(newUser)
|
||||
}
|
||||
|
||||
userData = await removingDuplicities({ groups, users })
|
||||
if (!userData.users.length) return
|
||||
|
||||
return createUser()
|
||||
}
|
||||
|
||||
async function createUser() {
|
||||
try {
|
||||
await users.create(await removingDuplicities(userData))
|
||||
notifications.success("Successfully created user")
|
||||
await groups.actions.init()
|
||||
passwordModal.show()
|
||||
} catch (error) {
|
||||
notifications.error("Error creating user")
|
||||
}
|
||||
}
|
||||
|
||||
async function chooseCreationType(onboardingType) {
|
||||
if (onboardingType === "emailOnboarding") {
|
||||
createUserFlow()
|
||||
} else {
|
||||
await createUser()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
await users.bulkDelete(ids)
|
||||
notifications.success(`Successfully deleted ${selectedRows.length} rows`)
|
||||
selectedRows = []
|
||||
await fetchUsers(page, searchEmail)
|
||||
} 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 (search && !prevSearch) {
|
||||
if (email && !prevEmail) {
|
||||
pageInfo.reset()
|
||||
page = undefined
|
||||
}
|
||||
prevSearch = search
|
||||
prevEmail = email
|
||||
try {
|
||||
pageInfo.loading()
|
||||
await users.search({ page, search })
|
||||
await users.search({ page, email })
|
||||
pageInfo.fetched($users.hasNextPage, $users.nextPage)
|
||||
} catch (error) {
|
||||
notifications.error("Error getting user list")
|
||||
|
@ -57,34 +237,49 @@
|
|||
<Layout noPadding>
|
||||
<Layout gap="XS" noPadding>
|
||||
<Heading>Users</Heading>
|
||||
<Body>
|
||||
Each user is assigned to a group that contains apps and permissions. In
|
||||
this section, you can add users, or edit and delete an existing user.
|
||||
</Body>
|
||||
<Body>Add users and control who gets access to your published apps</Body>
|
||||
|
||||
<div>
|
||||
{#each accessTypes as type}
|
||||
<div class="access-description">
|
||||
<Icon name={type.icon} />
|
||||
<div class="access-text">
|
||||
<Body size="S">{type.description}</Body>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Layout>
|
||||
<Divider size="S" />
|
||||
<Layout gap="S" noPadding>
|
||||
<div class="users-heading">
|
||||
<Heading size="S">Users</Heading>
|
||||
<ButtonGroup>
|
||||
<Button disabled secondary>Import users</Button>
|
||||
<Button primary dataCy="add-user" on:click={createUserModal.show}
|
||||
>Add user</Button
|
||||
>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
<div class="field">
|
||||
<Label size="L">Search / filter</Label>
|
||||
<Search bind:value={search} placeholder="" />
|
||||
</div>
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
dataCy="add-user"
|
||||
on:click={createUserModal.show}
|
||||
icon="UserAdd"
|
||||
cta>Add Users</Button
|
||||
>
|
||||
<Button on:click={importUsersModal.show} icon="Import" primary
|
||||
>Import Users</Button
|
||||
>
|
||||
|
||||
<div class="field">
|
||||
<Label size="L">Search email</Label>
|
||||
<Search bind:value={searchEmail} placeholder="" />
|
||||
</div>
|
||||
{#if selectedRows.length > 0}
|
||||
<DeleteRowsButton on:updaterows {selectedRows} {deleteRows} />
|
||||
{/if}
|
||||
</ButtonGroup>
|
||||
<Table
|
||||
on:click={({ detail }) => $goto(`./${detail._id}`)}
|
||||
{schema}
|
||||
data={$users.data}
|
||||
bind:selectedRows
|
||||
data={enrichedUsers}
|
||||
allowEditColumns={false}
|
||||
allowEditRows={false}
|
||||
allowSelectRows={false}
|
||||
customRenderers={[{ column: "group", component: TagsRenderer }]}
|
||||
allowSelectRows={true}
|
||||
showHeaderBorder={false}
|
||||
{customRenderers}
|
||||
/>
|
||||
<div class="pagination">
|
||||
<Pagination
|
||||
|
@ -99,12 +294,32 @@
|
|||
</Layout>
|
||||
|
||||
<Modal bind:this={createUserModal}>
|
||||
<AddUserModal
|
||||
on:created={async () => {
|
||||
pageInfo.reset()
|
||||
await fetchUsers()
|
||||
}}
|
||||
/>
|
||||
<AddUserModal {showOnboardingTypeModal} />
|
||||
</Modal>
|
||||
|
||||
<Modal bind:this={inviteConfirmationModal}>
|
||||
<ModalContent
|
||||
showCancelButton={false}
|
||||
title="Invites sent!"
|
||||
confirmText="Done"
|
||||
>
|
||||
<Body size="S"
|
||||
>Your users should now recieve an email invite to get access to their
|
||||
Budibase account</Body
|
||||
></ModalContent
|
||||
>
|
||||
</Modal>
|
||||
|
||||
<Modal bind:this={onboardingTypeModal}>
|
||||
<OnboardingTypeModal {chooseCreationType} />
|
||||
</Modal>
|
||||
|
||||
<Modal bind:this={passwordModal}>
|
||||
<PasswordModal userData={userData.users} />
|
||||
</Modal>
|
||||
|
||||
<Modal bind:this={importUsersModal}>
|
||||
<ImportUsersModal {createUsersFromCsv} />
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
|
@ -113,14 +328,20 @@
|
|||
align-items: center;
|
||||
flex-direction: row;
|
||||
grid-gap: var(--spacing-m);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.field > :global(*) + :global(*) {
|
||||
margin-left: var(--spacing-m);
|
||||
}
|
||||
.users-heading {
|
||||
|
||||
.access-description {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: var(--spacing-xl);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.access-text {
|
||||
margin-left: var(--spacing-m);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -19,6 +19,7 @@
|
|||
} from "@budibase/bbui"
|
||||
import OverviewTab from "../_components/OverviewTab.svelte"
|
||||
import SettingsTab from "../_components/SettingsTab.svelte"
|
||||
import AccessTab from "../_components/AccessTab.svelte"
|
||||
import { API } from "api"
|
||||
import { store } from "builderStore"
|
||||
import { apps, auth } from "stores/portal"
|
||||
|
@ -309,6 +310,9 @@
|
|||
on:unpublish={e => unpublishApp(e.detail)}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab title="Access">
|
||||
<AccessTab app={selectedApp} />
|
||||
</Tab>
|
||||
{#if isPublished}
|
||||
<Tab title="Automation History">
|
||||
<HistoryTab app={selectedApp} />
|
||||
|
|
|
@ -0,0 +1,267 @@
|
|||
<script>
|
||||
import {
|
||||
Layout,
|
||||
Heading,
|
||||
Body,
|
||||
Button,
|
||||
List,
|
||||
ListItem,
|
||||
Modal,
|
||||
notifications,
|
||||
Pagination,
|
||||
Icon,
|
||||
} from "@budibase/bbui"
|
||||
import { onMount } from "svelte"
|
||||
|
||||
import RoleSelect from "components/common/RoleSelect.svelte"
|
||||
import { users, groups, apps, auth } from "stores/portal"
|
||||
import AssignmentModal from "./AssignmentModal.svelte"
|
||||
import { createPaginationStore } from "helpers/pagination"
|
||||
import { Constants } from "@budibase/frontend-core"
|
||||
import { roles } from "stores/backend"
|
||||
|
||||
export let app
|
||||
let assignmentModal
|
||||
let appGroups = []
|
||||
let appUsers = []
|
||||
let prevSearch = undefined,
|
||||
search = undefined
|
||||
let pageInfo = createPaginationStore()
|
||||
let fixedAppId
|
||||
$: page = $pageInfo.page
|
||||
$: fetchUsers(page, search)
|
||||
|
||||
$: hasGroupsLicense = $auth.user?.license.features.includes(
|
||||
Constants.Features.USER_GROUPS
|
||||
)
|
||||
|
||||
$: fixedAppId = apps.getProdAppID(app.devId)
|
||||
|
||||
$: appUsers =
|
||||
$users.data?.filter(x => {
|
||||
return Object.keys(x.roles).find(y => {
|
||||
return y === fixedAppId
|
||||
})
|
||||
}) || []
|
||||
$: appGroups = $groups.filter(x => {
|
||||
return x.apps.includes(app.appId)
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
async function removeUser(user) {
|
||||
// Remove the user role
|
||||
const filteredRoles = { ...user.roles }
|
||||
delete filteredRoles[fixedAppId]
|
||||
await users.save({
|
||||
...user,
|
||||
roles: {
|
||||
...filteredRoles,
|
||||
},
|
||||
})
|
||||
await fetchUsers(page, search)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
async function updateUserRole(role, user) {
|
||||
user.roles[fixedAppId] = role
|
||||
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)
|
||||
} catch (error) {
|
||||
notifications.error("Error getting user list")
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
await groups.actions.init()
|
||||
await apps.load()
|
||||
await roles.fetch()
|
||||
} catch (error) {
|
||||
notifications.error(error)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="access-tab">
|
||||
<Layout>
|
||||
{#if appGroups.length || appUsers.length}
|
||||
<div>
|
||||
<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
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{#if hasGroupsLicense && appGroups.length}
|
||||
<List title="User Groups">
|
||||
{#each appGroups as group}
|
||||
<ListItem
|
||||
title={group.name}
|
||||
icon={group.icon}
|
||||
iconBackground={group.color}
|
||||
>
|
||||
<RoleSelect
|
||||
on:change={e => updateGroupRole(e.detail, group)}
|
||||
autoWidth
|
||||
quiet
|
||||
value={group.roles[
|
||||
Object.keys(group.roles).find(x => x === fixedAppId)
|
||||
]}
|
||||
/>
|
||||
<Icon
|
||||
on:click={() => removeGroup(group)}
|
||||
hoverable
|
||||
size="S"
|
||||
name="Close"
|
||||
/>
|
||||
</ListItem>
|
||||
{/each}
|
||||
</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={pageInfo.prevPage}
|
||||
goToNextPage={pageInfo.nextPage}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="align">
|
||||
<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
|
||||
>
|
||||
</div>
|
||||
<div class="padding">
|
||||
<Button on:click={() => assignmentModal.show()} cta icon="UserArrow"
|
||||
>Assign Users</Button
|
||||
>
|
||||
</div>
|
||||
</Layout>
|
||||
</div>
|
||||
{/if}
|
||||
</Layout>
|
||||
</div>
|
||||
|
||||
<Modal bind:this={assignmentModal}>
|
||||
<AssignmentModal {app} {appUsers} {addData} />
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.access-tab {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.padding {
|
||||
margin-top: var(--spacing-m);
|
||||
}
|
||||
.opacity {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.align {
|
||||
text-align: center;
|
||||
}
|
||||
.subtitle {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,103 @@
|
|||
<script>
|
||||
import {
|
||||
ModalContent,
|
||||
PickerDropdown,
|
||||
ActionButton,
|
||||
notifications,
|
||||
} from "@budibase/bbui"
|
||||
import { roles } from "stores/backend"
|
||||
import { groups, users } from "stores/portal"
|
||||
import { RoleUtils } from "@budibase/frontend-core"
|
||||
import { createPaginationStore } from "helpers/pagination"
|
||||
|
||||
export let app
|
||||
export let addData
|
||||
export let appUsers = []
|
||||
|
||||
let prevSearch = undefined,
|
||||
search = undefined
|
||||
let pageInfo = createPaginationStore()
|
||||
|
||||
$: page = $pageInfo.page
|
||||
$: fetchUsers(page, search)
|
||||
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, search })
|
||||
pageInfo.fetched($users.hasNextPage, $users.nextPage)
|
||||
} catch (error) {
|
||||
notifications.error("Error getting user list")
|
||||
}
|
||||
}
|
||||
|
||||
$: filteredGroups = $groups.filter(element => {
|
||||
return !element.apps.find(y => {
|
||||
return y.appId === app.appId
|
||||
})
|
||||
})
|
||||
|
||||
$: optionSections = {
|
||||
...(filteredGroups.length && {
|
||||
groups: {
|
||||
data: filteredGroups,
|
||||
getLabel: group => group.name,
|
||||
getValue: group => group._id,
|
||||
getIcon: group => group.icon,
|
||||
getColour: group => group.color,
|
||||
},
|
||||
}),
|
||||
users: {
|
||||
data: $users.data.filter(u => !appUsers.find(x => x._id === u._id)),
|
||||
getLabel: user => user.email,
|
||||
getValue: user => user._id,
|
||||
getIcon: user => user.icon,
|
||||
getColour: user => user.color,
|
||||
},
|
||||
}
|
||||
|
||||
$: appData = [{ id: "", role: "" }]
|
||||
|
||||
function addNewInput() {
|
||||
appData = [...appData, { id: "", role: "" }]
|
||||
}
|
||||
</script>
|
||||
|
||||
<ModalContent
|
||||
size="M"
|
||||
title="Assign users to your app"
|
||||
confirmText="Done"
|
||||
cancelText="Cancel"
|
||||
onConfirm={() => addData(appData)}
|
||||
showCloseIcon={false}
|
||||
>
|
||||
{#each appData as input, index}
|
||||
<PickerDropdown
|
||||
autocomplete
|
||||
primaryOptions={optionSections}
|
||||
placeholder={"Search Users"}
|
||||
secondaryOptions={$roles}
|
||||
bind:primaryValue={input.id}
|
||||
bind:secondaryValue={input.role}
|
||||
getPrimaryOptionLabel={group => group.name}
|
||||
getPrimaryOptionValue={group => group.name}
|
||||
getPrimaryOptionIcon={group => group.icon}
|
||||
getPrimaryOptionColour={group => group.colour}
|
||||
getSecondaryOptionLabel={role => role.name}
|
||||
getSecondaryOptionValue={role => role._id}
|
||||
getSecondaryOptionColour={role => RoleUtils.getRoleColour(role._id)}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<div>
|
||||
<ActionButton on:click={addNewInput} icon="Add">Add email</ActionButton>
|
||||
</div>
|
||||
</ModalContent>
|
|
@ -1,16 +1,17 @@
|
|||
<script>
|
||||
import DashCard from "components/common/DashCard.svelte"
|
||||
import { AppStatus } from "constants"
|
||||
import { Icon, Heading, Link, Avatar, Layout } from "@budibase/bbui"
|
||||
import { Icon, Heading, Link, Avatar, Layout, Body } from "@budibase/bbui"
|
||||
import { store } from "builderStore"
|
||||
import clientPackage from "@budibase/client/package.json"
|
||||
import { processStringSync } from "@budibase/string-templates"
|
||||
import { users, auth } from "stores/portal"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
import { createEventDispatcher, onMount } from "svelte"
|
||||
|
||||
export let app
|
||||
export let deployments
|
||||
export let navigateTab
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
const unpublishApp = () => {
|
||||
|
@ -37,6 +38,10 @@
|
|||
|
||||
return initials == "" ? user.email[0] : initials
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await users.search({ page: undefined, appId: "app_" + app.appId })
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="overview-tab">
|
||||
|
@ -132,6 +137,37 @@
|
|||
{/if}
|
||||
</div>
|
||||
</DashCard>
|
||||
<DashCard
|
||||
title={"Access"}
|
||||
showIcon={true}
|
||||
action={() => {
|
||||
navigateTab("Access")
|
||||
}}
|
||||
dataCy={"access"}
|
||||
>
|
||||
<div class="last-edited-content">
|
||||
{#if $users?.data?.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">
|
||||
{$users?.data.length} users have access to this app
|
||||
</div>
|
||||
</Layout>
|
||||
{:else}
|
||||
<Layout noPadding gap="S">
|
||||
<Body>No users</Body>
|
||||
<div class="users-text">
|
||||
No users have been assigned to this app
|
||||
</div>
|
||||
</Layout>
|
||||
{/if}
|
||||
</div>
|
||||
</DashCard>
|
||||
</div>
|
||||
{#if false}
|
||||
<div class="bottom">
|
||||
|
@ -186,6 +222,14 @@
|
|||
grid-template-columns: repeat(auto-fill, minmax(30%, 1fr));
|
||||
}
|
||||
|
||||
.users-tab {
|
||||
display: flex;
|
||||
gap: var(--spacing-m);
|
||||
}
|
||||
|
||||
.users-text {
|
||||
color: var(--spectrum-global-color-gray-600);
|
||||
}
|
||||
.overview-tab .bottom,
|
||||
.automation-metrics {
|
||||
display: grid;
|
||||
|
|
|
@ -7,6 +7,17 @@ const extractAppId = id => {
|
|||
return split.length ? split[split.length - 1] : null
|
||||
}
|
||||
|
||||
const getProdAppID = appId => {
|
||||
if (!appId || !appId.startsWith("app_dev")) {
|
||||
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}`
|
||||
}
|
||||
|
||||
export function createAppStore() {
|
||||
const store = writable([])
|
||||
|
||||
|
@ -78,6 +89,8 @@ export function createAppStore() {
|
|||
subscribe: store.subscribe,
|
||||
load,
|
||||
update,
|
||||
extractAppId,
|
||||
getProdAppID,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
import { writable, get } from "svelte/store"
|
||||
import { API } from "api"
|
||||
import { auth } from "stores/portal"
|
||||
import { Constants } from "@budibase/frontend-core"
|
||||
|
||||
export function createGroupsStore() {
|
||||
const store = writable([])
|
||||
|
||||
const actions = {
|
||||
init: async () => {
|
||||
// only init if these 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)
|
||||
}
|
||||
},
|
||||
|
||||
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
|
||||
})
|
||||
},
|
||||
|
||||
delete: async group => {
|
||||
await API.deleteGroup({
|
||||
id: group._id,
|
||||
rev: group._rev,
|
||||
})
|
||||
store.update(state => {
|
||||
state = state.filter(state => state._id !== group._id)
|
||||
return state
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe: store.subscribe,
|
||||
actions,
|
||||
}
|
||||
}
|
||||
|
||||
export const groups = createGroupsStore()
|
|
@ -7,3 +7,4 @@ export { auth } from "./auth"
|
|||
export { oidc } from "./oidc"
|
||||
export { templates } from "./templates"
|
||||
export { licensing } from "./licensing"
|
||||
export { groups } from "./groups"
|
||||
|
|
|
@ -22,15 +22,17 @@ export function createUsersStore() {
|
|||
return null
|
||||
}
|
||||
}
|
||||
const fetch = async () => {
|
||||
return await API.getUsers()
|
||||
}
|
||||
|
||||
async function invite({ email, builder, admin }) {
|
||||
return API.inviteUser({
|
||||
email,
|
||||
async function invite({ emails, builder, admin }) {
|
||||
return API.inviteUsers({
|
||||
emails,
|
||||
builder,
|
||||
admin,
|
||||
})
|
||||
}
|
||||
|
||||
async function acceptInvite(inviteCode, password) {
|
||||
return API.acceptInvite({
|
||||
inviteCode,
|
||||
|
@ -38,28 +40,34 @@ export function createUsersStore() {
|
|||
})
|
||||
}
|
||||
|
||||
async function create({
|
||||
email,
|
||||
password,
|
||||
admin,
|
||||
builder,
|
||||
forceResetPassword,
|
||||
}) {
|
||||
const body = {
|
||||
email,
|
||||
password,
|
||||
roles: {},
|
||||
}
|
||||
if (forceResetPassword) {
|
||||
body.forceResetPassword = forceResetPassword
|
||||
}
|
||||
if (builder) {
|
||||
body.builder = { global: true }
|
||||
}
|
||||
if (admin) {
|
||||
body.admin = { global: true }
|
||||
}
|
||||
await API.saveUser(body)
|
||||
async function create(data) {
|
||||
let mappedUsers = data.users.map(user => {
|
||||
const body = {
|
||||
email: user.email,
|
||||
password: user.password,
|
||||
roles: {},
|
||||
}
|
||||
if (user.forceResetPassword) {
|
||||
body.forceResetPassword = user.forceResetPassword
|
||||
}
|
||||
|
||||
switch (user.role) {
|
||||
case "appUser":
|
||||
body.builder = { global: false }
|
||||
body.admin = { global: false }
|
||||
break
|
||||
case "developer":
|
||||
body.builder = { global: true }
|
||||
break
|
||||
case "admin":
|
||||
body.admin = { global: true }
|
||||
break
|
||||
}
|
||||
|
||||
return body
|
||||
})
|
||||
await API.createUsers({ users: mappedUsers, groups: data.groups })
|
||||
|
||||
// re-search from first page
|
||||
await search()
|
||||
}
|
||||
|
@ -69,18 +77,28 @@ export function createUsersStore() {
|
|||
update(users => users.filter(user => user._id !== id))
|
||||
}
|
||||
|
||||
async function save(data) {
|
||||
await API.saveUser(data)
|
||||
async function bulkDelete(userIds) {
|
||||
await API.deleteUsers(userIds)
|
||||
}
|
||||
|
||||
async function save(user) {
|
||||
return await API.saveUser(user)
|
||||
}
|
||||
|
||||
const getUserRole = ({ admin, builder }) =>
|
||||
admin?.global ? "admin" : builder?.global ? "developer" : "appUser"
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
search,
|
||||
get,
|
||||
getUserRole,
|
||||
fetch,
|
||||
invite,
|
||||
acceptInvite,
|
||||
create,
|
||||
save,
|
||||
bulkDelete,
|
||||
delete: del,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
export const buildGroupsEndpoints = API => ({
|
||||
/**
|
||||
* Creates a user group.
|
||||
* @param user the new group to create
|
||||
*/
|
||||
saveGroup: async group => {
|
||||
return await API.post({
|
||||
url: "/api/global/groups",
|
||||
body: group,
|
||||
})
|
||||
},
|
||||
/**
|
||||
* 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}`,
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 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}`,
|
||||
})
|
||||
},
|
||||
})
|
|
@ -23,6 +23,7 @@ import { buildUserEndpoints } from "./user"
|
|||
import { buildSelfEndpoints } from "./self"
|
||||
import { buildViewEndpoints } from "./views"
|
||||
import { buildLicensingEndpoints } from "./licensing"
|
||||
import { buildGroupsEndpoints } from "./groups"
|
||||
|
||||
const defaultAPIClientConfig = {
|
||||
/**
|
||||
|
@ -241,5 +242,6 @@ export const createAPIClient = config => {
|
|||
...buildViewEndpoints(API),
|
||||
...buildSelfEndpoints(API),
|
||||
...buildLicensingEndpoints(API),
|
||||
...buildGroupsEndpoints(API),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,13 +13,16 @@ export const buildUserEndpoints = API => ({
|
|||
* @param {string} page The page to retrieve
|
||||
* @param {string} search The starts with string to search username/email by.
|
||||
*/
|
||||
searchUsers: async ({ page, search } = {}) => {
|
||||
searchUsers: async ({ page, email, appId } = {}) => {
|
||||
const opts = {}
|
||||
if (page) {
|
||||
opts.page = page
|
||||
}
|
||||
if (search) {
|
||||
opts.search = search
|
||||
if (email) {
|
||||
opts.email = email
|
||||
}
|
||||
if (appId) {
|
||||
opts.appId = appId
|
||||
}
|
||||
return await API.post({
|
||||
url: `/api/global/users/search`,
|
||||
|
@ -80,6 +83,20 @@ export const buildUserEndpoints = API => ({
|
|||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates multiple users.
|
||||
* @param users the array of user objects to create
|
||||
*/
|
||||
createUsers: async ({ users, groups }) => {
|
||||
return await API.post({
|
||||
url: "/api/global/users/bulkCreate",
|
||||
body: {
|
||||
users,
|
||||
groups,
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Deletes a user from the curernt tenant.
|
||||
* @param userId the ID of the user to delete
|
||||
|
@ -90,6 +107,19 @@ export const buildUserEndpoints = API => ({
|
|||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Deletes multiple users
|
||||
* @param userId the ID of the user to delete
|
||||
*/
|
||||
deleteUsers: async userIds => {
|
||||
return await API.post({
|
||||
url: `/api/global/users/bulkDelete`,
|
||||
body: {
|
||||
userIds,
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Invites a user to the current tenant.
|
||||
* @param email the email address to send the invitation to
|
||||
|
@ -109,6 +139,25 @@ export const buildUserEndpoints = API => ({
|
|||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Invites multiple users to the current tenant.
|
||||
* @param email An array of email addresses
|
||||
* @param builder whether the user should be a global builder
|
||||
* @param admin whether the user should be a global admin
|
||||
*/
|
||||
inviteUsers: async ({ emails, builder, admin }) => {
|
||||
return await API.post({
|
||||
url: "/api/global/users/inviteMultiple",
|
||||
body: {
|
||||
emails,
|
||||
userInfo: {
|
||||
admin: admin ? { global: true } : undefined,
|
||||
builder: builder ? { global: true } : undefined,
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Accepts an invite to join the platform and creates a user.
|
||||
* @param inviteCode the invite code sent in the email
|
||||
|
|
|
@ -60,6 +60,37 @@ export const TableNames = {
|
|||
USERS: "ta_users",
|
||||
}
|
||||
|
||||
export const BbRoles = [
|
||||
{ label: "App User", value: "appUser" },
|
||||
{ label: "Developer", value: "developer" },
|
||||
{ label: "Admin", value: "admin" },
|
||||
]
|
||||
|
||||
export const BuilderRoleDescriptions = [
|
||||
{
|
||||
value: "appUser",
|
||||
icon: "User",
|
||||
label: "App user - Only has access to published apps",
|
||||
},
|
||||
{
|
||||
value: "developer",
|
||||
icon: "Hammer",
|
||||
label: "Developer - Access to the app builder",
|
||||
},
|
||||
{
|
||||
value: "admin",
|
||||
icon: "Draw",
|
||||
label: "Admin - Full access",
|
||||
},
|
||||
]
|
||||
|
||||
export const PlanType = {
|
||||
FREE: "free",
|
||||
TEAM: "team",
|
||||
BUSINESS: "business",
|
||||
ENTERPRISE: "enterprise",
|
||||
}
|
||||
|
||||
/**
|
||||
* API version header attached to all requests.
|
||||
* Version changelog:
|
||||
|
@ -68,6 +99,10 @@ export const TableNames = {
|
|||
*/
|
||||
export const ApiVersion = "1"
|
||||
|
||||
export const Features = {
|
||||
USER_GROUPS: "userGroups",
|
||||
}
|
||||
|
||||
// Role IDs
|
||||
export const Roles = {
|
||||
ADMIN: "ADMIN",
|
||||
|
@ -76,7 +111,6 @@ export const Roles = {
|
|||
PUBLIC: "PUBLIC",
|
||||
BUILDER: "BUILDER",
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum minimum range for SQL number values
|
||||
*/
|
||||
|
|
|
@ -246,7 +246,7 @@ async function execute(
|
|||
}
|
||||
|
||||
export async function executeV1(ctx: any) {
|
||||
return execute(ctx, { rowsOnly: true })
|
||||
return execute(ctx, { rowsOnly: true, isAutomation: false })
|
||||
}
|
||||
|
||||
export async function executeV2(
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
const joiValidator = require("../../../middleware/joi-validator")
|
||||
const { joiValidator } = require("@budibase/backend-core/auth")
|
||||
const Joi = require("joi")
|
||||
|
||||
const OPTIONAL_STRING = Joi.string().optional().allow(null).allow("")
|
||||
|
|
|
@ -23,6 +23,7 @@ describe("/users", () => {
|
|||
})
|
||||
|
||||
describe("fetch", () => {
|
||||
|
||||
it("returns a list of users from an instance db", async () => {
|
||||
await config.createUser("uuidx")
|
||||
await config.createUser("uuidy")
|
||||
|
@ -37,6 +38,7 @@ describe("/users", () => {
|
|||
expect(res.body.find(u => u._id === `ro_ta_users_us_uuidy`)).toBeDefined()
|
||||
})
|
||||
|
||||
|
||||
it("should apply authorization to endpoint", async () => {
|
||||
await config.createUser()
|
||||
await checkPermissionsEndpoint({
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
const joiValidator = require("../../../middleware/joi-validator")
|
||||
const { joiValidator } = require("@budibase/backend-core/auth")
|
||||
const { DataSourceOperation } = require("../../../constants")
|
||||
const { WebhookType } = require("../../../constants")
|
||||
const {
|
||||
|
|
|
@ -74,6 +74,7 @@ exports.run = async function ({ inputs, appId, emitter }) {
|
|||
try {
|
||||
await queryController.executeV2(ctx, { isAutomation: true })
|
||||
const { data, ...rest } = ctx.body
|
||||
|
||||
return {
|
||||
response: data,
|
||||
info: rest,
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
mockAuthWithNoCookie()
|
||||
mockWorker()
|
||||
mockUserGroups()
|
||||
|
||||
jest.mock("@budibase/backend-core/db", () => {
|
||||
const coreDb = jest.requireActual("@budibase/backend-core/db")
|
||||
|
@ -29,6 +30,16 @@ function mockReset() {
|
|||
mockWorker()
|
||||
}
|
||||
|
||||
function mockUserGroups() {
|
||||
jest.mock("@budibase/pro", () => ({
|
||||
groups: {
|
||||
getGroupRoleId: () => {
|
||||
return "BASIC"
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function mockAuthWithNoCookie() {
|
||||
jest.resetModules()
|
||||
mockWorker()
|
||||
|
|
|
@ -12,9 +12,11 @@ const {
|
|||
} = require("@budibase/backend-core/tenancy")
|
||||
const env = require("../environment")
|
||||
const { getAppId } = require("@budibase/backend-core/context")
|
||||
const { groups } = require("@budibase/pro")
|
||||
|
||||
exports.updateAppRole = (user, { appId } = {}) => {
|
||||
appId = appId || getAppId()
|
||||
|
||||
if (!user || !user.roles) {
|
||||
return user
|
||||
}
|
||||
|
@ -33,15 +35,30 @@ exports.updateAppRole = (user, { appId } = {}) => {
|
|||
} else if (!user.roleId) {
|
||||
user.roleId = BUILTIN_ROLE_IDS.PUBLIC
|
||||
}
|
||||
|
||||
delete user.roles
|
||||
return user
|
||||
}
|
||||
|
||||
function processUser(user, { appId } = {}) {
|
||||
async function checkGroupRoles(user, { appId } = {}) {
|
||||
if (!user.roleId) {
|
||||
let roleId = await groups.getGroupRoleId(user, appId)
|
||||
user.roleId = roleId
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
async function processUser(user, { appId } = {}) {
|
||||
if (user) {
|
||||
delete user.password
|
||||
}
|
||||
return exports.updateAppRole(user, { appId })
|
||||
user = await exports.updateAppRole(user, { appId })
|
||||
if (user?.userGroups?.length) {
|
||||
user = await checkGroupRoles(user, { appId })
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
exports.getCachedSelf = async (ctx, appId) => {
|
||||
|
@ -89,6 +106,7 @@ exports.getGlobalUsers = async (users = null) => {
|
|||
if (!appId) {
|
||||
return globalUsers
|
||||
}
|
||||
|
||||
return globalUsers.map(user => exports.updateAppRole(user))
|
||||
}
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,2 +1,3 @@
|
|||
export * from "./config"
|
||||
export * from "./user"
|
||||
export * from "./userGroup"
|
||||
|
|
|
@ -14,6 +14,7 @@ export interface User extends Document {
|
|||
password?: string
|
||||
status?: string
|
||||
createdAt?: number // override the default createdAt behaviour - users sdk historically set this to Date.now()
|
||||
userGroups?: string[]
|
||||
}
|
||||
|
||||
export interface UserRoles {
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
import { Document } from "../document"
|
||||
import { User } from "./user"
|
||||
export interface UserGroup extends Document {
|
||||
name: string
|
||||
icon: string
|
||||
color: string
|
||||
users: groupUser[]
|
||||
apps: string[]
|
||||
roles: UserGroupRoles
|
||||
createdAt?: number
|
||||
}
|
||||
|
||||
export interface groupUser {
|
||||
_id: string
|
||||
email: string[]
|
||||
}
|
||||
export interface UserGroupRoles {
|
||||
[key: string]: string
|
||||
}
|
|
@ -150,6 +150,15 @@ export enum Event {
|
|||
TENANT_BACKFILL_FAILED = "tenant:backfill:failed",
|
||||
INSTALLATION_BACKFILL_SUCCEEDED = "installation:backfill:succeeded",
|
||||
INSTALLATION_BACKFILL_FAILED = "installation:backfill:failed",
|
||||
|
||||
// USER
|
||||
USER_GROUP_CREATED = "user_group:created",
|
||||
USER_GROUP_UPDATED = "user_group:updated",
|
||||
USER_GROUP_DELETED = "user_group:deleted",
|
||||
USER_GROUP_USERS_ADDED = "user_group:user_added",
|
||||
USER_GROUP_USERS_REMOVED = "user_group:users_deleted",
|
||||
USER_GROUP_PERMISSIONS_EDITED = "user_group:permissions_edited",
|
||||
USER_GROUP_ONBOARDING = "user_group:onboarding_added",
|
||||
}
|
||||
|
||||
// properties added at the final stage of the event pipeline
|
||||
|
|
|
@ -18,3 +18,4 @@ export * from "./view"
|
|||
export * from "./account"
|
||||
export * from "./backfill"
|
||||
export * from "./identification"
|
||||
export * from "./userGroup"
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
import { BaseEvent } from "./event"
|
||||
|
||||
export interface GroupCreatedEvent extends BaseEvent {
|
||||
groupId: string
|
||||
}
|
||||
|
||||
export interface GroupUpdatedEvent extends BaseEvent {
|
||||
groupId: string
|
||||
}
|
||||
|
||||
export interface GroupDeletedEvent extends BaseEvent {
|
||||
groupId: string
|
||||
}
|
||||
|
||||
export interface GroupUsersAddedEvent extends BaseEvent {
|
||||
count: number
|
||||
groupId: string
|
||||
}
|
||||
|
||||
export interface GroupUsersDeletedEvent extends BaseEvent {
|
||||
count: number
|
||||
groupId: string
|
||||
}
|
||||
|
||||
export interface GroupAddedOnboardingEvent extends BaseEvent {
|
||||
groupId: string
|
||||
onboarding: boolean
|
||||
}
|
|
@ -3,7 +3,7 @@ import { checkInviteCode } from "../../../utilities/redis"
|
|||
import { sendEmail } from "../../../utilities/email"
|
||||
import { users } from "../../../sdk"
|
||||
import env from "../../../environment"
|
||||
import { User, CloudAccount } from "@budibase/types"
|
||||
import { User, CloudAccount, UserGroup } from "@budibase/types"
|
||||
import {
|
||||
events,
|
||||
errors,
|
||||
|
@ -13,6 +13,8 @@ import {
|
|||
cache,
|
||||
} from "@budibase/backend-core"
|
||||
import { checkAnyUserExists } from "../../../utilities/users"
|
||||
import { groups as groupUtils } from "@budibase/pro"
|
||||
const MAX_USERS_UPLOAD_LIMIT = 1000
|
||||
|
||||
export const save = async (ctx: any) => {
|
||||
try {
|
||||
|
@ -22,6 +24,36 @@ export const save = async (ctx: any) => {
|
|||
}
|
||||
}
|
||||
|
||||
export const bulkCreate = async (ctx: any) => {
|
||||
let { users: newUsersRequested, groups } = ctx.request.body
|
||||
|
||||
if (!env.SELF_HOSTED && newUsersRequested.length > MAX_USERS_UPLOAD_LIMIT) {
|
||||
ctx.throw(
|
||||
400,
|
||||
"Max limit for upload is 1000 users. Please reduce file size and try again."
|
||||
)
|
||||
}
|
||||
|
||||
const db = tenancy.getGlobalDB()
|
||||
let groupsToSave: any[] = []
|
||||
|
||||
if (groups.length) {
|
||||
for (const groupId of groups) {
|
||||
let oldGroup = await db.get(groupId)
|
||||
groupsToSave.push(oldGroup)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let response = await users.bulkCreate(newUsersRequested, groups)
|
||||
await groupUtils.bulkSaveGroupUsers(groupsToSave, response)
|
||||
|
||||
ctx.body = response
|
||||
} catch (err: any) {
|
||||
ctx.throw(err.status || 400, err)
|
||||
}
|
||||
}
|
||||
|
||||
const parseBooleanParam = (param: any) => {
|
||||
return !(param && param === "false")
|
||||
}
|
||||
|
@ -84,12 +116,27 @@ export const adminUser = async (ctx: any) => {
|
|||
|
||||
export const destroy = async (ctx: any) => {
|
||||
const id = ctx.params.id
|
||||
|
||||
await users.destroy(id, ctx.user)
|
||||
|
||||
ctx.body = {
|
||||
message: `User ${id} deleted.`,
|
||||
}
|
||||
}
|
||||
|
||||
export const bulkDelete = async (ctx: any) => {
|
||||
const { userIds } = ctx.request.body
|
||||
try {
|
||||
let usersResponse = await users.bulkDelete(userIds)
|
||||
|
||||
ctx.body = {
|
||||
message: `${usersResponse.length} user(s) deleted`,
|
||||
}
|
||||
} catch (err) {
|
||||
ctx.throw(err)
|
||||
}
|
||||
}
|
||||
|
||||
export const search = async (ctx: any) => {
|
||||
const paginated = await users.paginatedUsers(ctx.request.body)
|
||||
// user hashed password shouldn't ever be returned
|
||||
|
@ -149,6 +196,39 @@ export const invite = async (ctx: any) => {
|
|||
await events.user.invited()
|
||||
}
|
||||
|
||||
export const inviteMultiple = async (ctx: any) => {
|
||||
let { emails, userInfo } = ctx.request.body
|
||||
let existing = false
|
||||
let existingEmail
|
||||
for (let email of emails) {
|
||||
if (await usersCore.getGlobalUserByEmail(email)) {
|
||||
existing = true
|
||||
existingEmail = email
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
ctx.throw(400, `${existingEmail} already exists`)
|
||||
}
|
||||
if (!userInfo) {
|
||||
userInfo = {}
|
||||
}
|
||||
userInfo.tenantId = tenancy.getTenantId()
|
||||
const opts: any = {
|
||||
subject: "{{ company }} platform invitation",
|
||||
info: userInfo,
|
||||
}
|
||||
|
||||
for (let i = 0; i < emails.length; i++) {
|
||||
await sendEmail(emails[i], EmailTemplatePurpose.INVITATION, opts)
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
message: "Invitations have been sent.",
|
||||
}
|
||||
}
|
||||
|
||||
export const inviteAccept = async (ctx: any) => {
|
||||
const { inviteCode, password, firstName, lastName } = ctx.request.body
|
||||
try {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
const Router = require("@koa/router")
|
||||
const authController = require("../../controllers/global/auth")
|
||||
const joiValidator = require("../../../middleware/joi-validator")
|
||||
const { joiValidator } = require("@budibase/backend-core/auth")
|
||||
const Joi = require("joi")
|
||||
const { updateTenantId } = require("@budibase/backend-core/tenancy")
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
const Router = require("@koa/router")
|
||||
const controller = require("../../controllers/global/configs")
|
||||
const joiValidator = require("../../../middleware/joi-validator")
|
||||
const adminOnly = require("../../../middleware/adminOnly")
|
||||
const { joiValidator } = require("@budibase/backend-core/auth")
|
||||
const { adminOnly } = require("@budibase/backend-core/auth")
|
||||
const Joi = require("joi")
|
||||
const { Configs } = require("../../../constants")
|
||||
|
||||
|
@ -65,6 +65,8 @@ function buildConfigSaveValidation() {
|
|||
_rev: Joi.string().optional(),
|
||||
workspace: Joi.string().optional(),
|
||||
type: Joi.string().valid(...Object.values(Configs)).required(),
|
||||
createdAt: Joi.string().optional(),
|
||||
updatedAt: Joi.string().optional(),
|
||||
config: Joi.alternatives()
|
||||
.conditional("type", {
|
||||
switch: [
|
||||
|
@ -75,7 +77,7 @@ function buildConfigSaveValidation() {
|
|||
{ is: Configs.OIDC, then: oidcValidation() }
|
||||
],
|
||||
}),
|
||||
}).required().unknown(true),
|
||||
}).required().unknown(true),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
const Router = require("@koa/router")
|
||||
const controller = require("../../controllers/global/email")
|
||||
const { EmailTemplatePurpose } = require("../../../constants")
|
||||
const joiValidator = require("../../../middleware/joi-validator")
|
||||
const adminOnly = require("../../../middleware/adminOnly")
|
||||
const { joiValidator } = require("@budibase/backend-core/auth")
|
||||
const { adminOnly } = require("@budibase/backend-core/auth")
|
||||
const Joi = require("joi")
|
||||
|
||||
const router = Router()
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
const Router = require("@koa/router")
|
||||
const controller = require("../../controllers/global/roles")
|
||||
const adminOnly = require("../../../middleware/adminOnly")
|
||||
const { adminOnly } = require("@budibase/backend-core/auth")
|
||||
|
||||
const router = Router()
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
const Router = require("@koa/router")
|
||||
const controller = require("../../controllers/global/sessions")
|
||||
const adminOnly = require("../../../middleware/adminOnly")
|
||||
const { adminOnly } = require("@budibase/backend-core/auth")
|
||||
|
||||
const router = Router()
|
||||
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
const Router = require("@koa/router")
|
||||
const controller = require("../../controllers/global/templates")
|
||||
const joiValidator = require("../../../middleware/joi-validator")
|
||||
const { joiValidator } = require("@budibase/backend-core/auth")
|
||||
const Joi = require("joi")
|
||||
const { TemplatePurpose, TemplateTypes } = require("../../../constants")
|
||||
const adminOnly = require("../../../middleware/adminOnly")
|
||||
const { adminOnly } = require("@budibase/backend-core/auth")
|
||||
|
||||
const router = Router()
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
const Router = require("@koa/router")
|
||||
const controller = require("../../controllers/global/users")
|
||||
const joiValidator = require("../../../middleware/joi-validator")
|
||||
const adminOnly = require("../../../middleware/adminOnly")
|
||||
const { joiValidator } = require("@budibase/backend-core/auth")
|
||||
const { adminOnly } = require("@budibase/backend-core/auth")
|
||||
const Joi = require("joi")
|
||||
const cloudRestricted = require("../../../middleware/cloudRestricted")
|
||||
const { users } = require("../validation")
|
||||
|
@ -30,6 +30,14 @@ function buildInviteValidation() {
|
|||
}).required())
|
||||
}
|
||||
|
||||
function buildInviteMultipleValidation() {
|
||||
// prettier-ignore
|
||||
return joiValidator.body(Joi.object({
|
||||
emails: Joi.array().required(),
|
||||
userInfo: Joi.object().optional(),
|
||||
}).required())
|
||||
}
|
||||
|
||||
function buildInviteAcceptValidation() {
|
||||
// prettier-ignore
|
||||
return joiValidator.body(Joi.object({
|
||||
|
@ -45,9 +53,17 @@ router
|
|||
users.buildUserSaveValidation(),
|
||||
controller.save
|
||||
)
|
||||
.post(
|
||||
"/api/global/users/bulkCreate",
|
||||
adminOnly,
|
||||
users.buildUserBulkSaveValidation(),
|
||||
controller.bulkCreate
|
||||
)
|
||||
|
||||
.get("/api/global/users", builderOrAdmin, controller.fetch)
|
||||
.post("/api/global/users/search", builderOrAdmin, controller.search)
|
||||
.delete("/api/global/users/:id", adminOnly, controller.destroy)
|
||||
.post("/api/global/users/bulkDelete", adminOnly, controller.bulkDelete)
|
||||
.get("/api/global/roles/:appId")
|
||||
.post(
|
||||
"/api/global/users/invite",
|
||||
|
@ -55,6 +71,19 @@ router
|
|||
buildInviteValidation(),
|
||||
controller.invite
|
||||
)
|
||||
.post(
|
||||
"/api/global/users/invite",
|
||||
adminOnly,
|
||||
buildInviteValidation(),
|
||||
controller.invite
|
||||
)
|
||||
.post(
|
||||
"/api/global/users/inviteMultiple",
|
||||
adminOnly,
|
||||
buildInviteMultipleValidation(),
|
||||
controller.inviteMultiple
|
||||
)
|
||||
|
||||
// non-global endpoints
|
||||
.post(
|
||||
"/api/global/users/invite/accept",
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
const Router = require("@koa/router")
|
||||
const controller = require("../../controllers/global/workspaces")
|
||||
const joiValidator = require("../../../middleware/joi-validator")
|
||||
const adminOnly = require("../../../middleware/adminOnly")
|
||||
const { joiValidator } = require("@budibase/backend-core/auth")
|
||||
const { adminOnly } = require("@budibase/backend-core/auth")
|
||||
const Joi = require("joi")
|
||||
|
||||
const router = Router()
|
||||
|
@ -17,9 +17,9 @@ function buildWorkspaceSaveValidation() {
|
|||
roles: Joi.object({
|
||||
default: Joi.string().optional(),
|
||||
app: Joi.object()
|
||||
.pattern(/.*/, Joi.string())
|
||||
.required()
|
||||
.unknown(true),
|
||||
.pattern(/.*/, Joi.string())
|
||||
.required()
|
||||
.unknown(true),
|
||||
}).unknown(true).optional(),
|
||||
}).required().unknown(true))
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
const { api } = require("@budibase/pro")
|
||||
const userRoutes = require("./global/users")
|
||||
const configRoutes = require("./global/configs")
|
||||
const workspaceRoutes = require("./global/workspaces")
|
||||
|
@ -12,6 +13,7 @@ const statusRoutes = require("./system/status")
|
|||
const selfRoutes = require("./global/self")
|
||||
const licenseRoutes = require("./global/license")
|
||||
|
||||
let userGroupRoutes = api.groups
|
||||
exports.routes = [
|
||||
configRoutes,
|
||||
userRoutes,
|
||||
|
@ -26,4 +28,5 @@ exports.routes = [
|
|||
statusRoutes,
|
||||
selfRoutes,
|
||||
licenseRoutes,
|
||||
userGroupRoutes,
|
||||
]
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
const Router = require("@koa/router")
|
||||
const controller = require("../../controllers/system/tenants")
|
||||
const adminOnly = require("../../../middleware/adminOnly")
|
||||
const { adminOnly } = require("@budibase/backend-core/auth")
|
||||
|
||||
const router = Router()
|
||||
|
||||
|
|
|
@ -2,7 +2,6 @@ jest.mock("nodemailer")
|
|||
const { config, request, mocks, structures } = require("../../../tests")
|
||||
const sendMailMock = mocks.email.mock()
|
||||
const { events } = require("@budibase/backend-core")
|
||||
|
||||
describe("/api/global/users", () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
|
@ -24,9 +23,9 @@ describe("/api/global/users", () => {
|
|||
.set(config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
|
||||
const emailCall = sendMailMock.mock.calls[0][0]
|
||||
// after this URL there should be a code
|
||||
|
||||
const emailCall = sendMailMock.mock.calls[0][0]
|
||||
// after this URL there should be a code
|
||||
const parts = emailCall.html.split("http://localhost:10000/builder/invite?code=")
|
||||
const code = parts[1].split("\"")[0].split("&")[0]
|
||||
return { code, res }
|
||||
|
@ -60,7 +59,7 @@ describe("/api/global/users", () => {
|
|||
expect(events.user.inviteAccepted).toBeCalledWith(user)
|
||||
})
|
||||
|
||||
const createUser = async (user) => {
|
||||
const createUser = async (user) => {
|
||||
const existing = await config.getUser(user.email)
|
||||
if (existing) {
|
||||
await deleteUser(existing._id)
|
||||
|
@ -84,14 +83,37 @@ describe("/api/global/users", () => {
|
|||
return res.body
|
||||
}
|
||||
|
||||
|
||||
const bulkCreateUsers = async (users) => {
|
||||
const res = await request
|
||||
.post(`/api/global/users/bulkCreate`)
|
||||
.send(users)
|
||||
.set(config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
return res.body
|
||||
}
|
||||
|
||||
const bulkDeleteUsers = async (users) => {
|
||||
const res = await request
|
||||
.post(`/api/global/users/bulkDelete`)
|
||||
.send(users)
|
||||
.set(config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
return res.body
|
||||
}
|
||||
|
||||
|
||||
|
||||
const deleteUser = async (email) => {
|
||||
const user = await config.getUser(email)
|
||||
if (user) {
|
||||
await request
|
||||
.delete(`/api/global/users/${user._id}`)
|
||||
.set(config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
.delete(`/api/global/users/${user._id}`)
|
||||
.set(config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -107,10 +129,25 @@ describe("/api/global/users", () => {
|
|||
expect(events.user.permissionAdminAssigned).not.toBeCalled()
|
||||
})
|
||||
|
||||
it("should be able to bulkCreate users with different permissions", async () => {
|
||||
jest.clearAllMocks()
|
||||
const builder = structures.users.builderUser({ email: "bulkbasic@test.com" })
|
||||
const admin = structures.users.adminUser({ email: "bulkadmin@test.com" })
|
||||
const user = structures.users.user({ email: "bulkuser@test.com" })
|
||||
|
||||
let toCreate = { users: [builder, admin, user], groups: [] }
|
||||
await bulkCreateUsers(toCreate)
|
||||
|
||||
expect(events.user.created).toBeCalledTimes(3)
|
||||
expect(events.user.permissionAdminAssigned).toBeCalledTimes(1)
|
||||
expect(events.user.permissionBuilderAssigned).toBeCalledTimes(1)
|
||||
})
|
||||
|
||||
|
||||
it("should be able to create an admin user", async () => {
|
||||
jest.clearAllMocks()
|
||||
const user = structures.users.adminUser({ email: "admin@test.com" })
|
||||
await createUser(user)
|
||||
await createUser(user)
|
||||
|
||||
expect(events.user.created).toBeCalledTimes(1)
|
||||
expect(events.user.updated).not.toBeCalled()
|
||||
|
@ -333,5 +370,21 @@ describe("/api/global/users", () => {
|
|||
expect(events.user.permissionBuilderRemoved).toBeCalledTimes(1)
|
||||
expect(events.user.permissionAdminRemoved).not.toBeCalled()
|
||||
})
|
||||
|
||||
it("should be able to bulk delete users with different permissions", async () => {
|
||||
jest.clearAllMocks()
|
||||
const builder = structures.users.builderUser({ email: "basic@test.com" })
|
||||
const admin = structures.users.adminUser({ email: "admin@test.com" })
|
||||
const user = structures.users.user({ email: "user@test.com" })
|
||||
|
||||
let toCreate = { users: [builder, admin, user], groups: [] }
|
||||
let createdUsers = await bulkCreateUsers(toCreate)
|
||||
await bulkDeleteUsers({ userIds: [createdUsers[0]._id, createdUsers[1]._id, createdUsers[2]._id] })
|
||||
expect(events.user.deleted).toBeCalledTimes(3)
|
||||
expect(events.user.permissionAdminRemoved).toBeCalledTimes(1)
|
||||
expect(events.user.permissionBuilderRemoved).toBeCalledTimes(1)
|
||||
|
||||
})
|
||||
|
||||
})
|
||||
})
|
|
@ -1,22 +1,23 @@
|
|||
import joiValidator from "../../../middleware/joi-validator"
|
||||
import Joi from "joi"
|
||||
|
||||
let schema: any = {
|
||||
email: Joi.string().allow(null, ""),
|
||||
password: Joi.string().allow(null, ""),
|
||||
forceResetPassword: Joi.boolean().optional(),
|
||||
firstName: Joi.string().allow(null, ""),
|
||||
lastName: Joi.string().allow(null, ""),
|
||||
builder: Joi.object({
|
||||
global: Joi.boolean().optional(),
|
||||
apps: Joi.array().optional(),
|
||||
})
|
||||
.unknown(true)
|
||||
.optional(),
|
||||
// maps appId -> roleId for the user
|
||||
roles: Joi.object().pattern(/.*/, Joi.string()).required().unknown(true),
|
||||
}
|
||||
|
||||
export const buildUserSaveValidation = (isSelf = false) => {
|
||||
let schema: any = {
|
||||
email: Joi.string().allow(null, ""),
|
||||
password: Joi.string().allow(null, ""),
|
||||
forceResetPassword: Joi.boolean().optional(),
|
||||
firstName: Joi.string().allow(null, ""),
|
||||
lastName: Joi.string().allow(null, ""),
|
||||
builder: Joi.object({
|
||||
global: Joi.boolean().optional(),
|
||||
apps: Joi.array().optional(),
|
||||
})
|
||||
.unknown(true)
|
||||
.optional(),
|
||||
// maps appId -> roleId for the user
|
||||
roles: Joi.object().pattern(/.*/, Joi.string()).required().unknown(true),
|
||||
}
|
||||
if (!isSelf) {
|
||||
schema = {
|
||||
...schema,
|
||||
|
@ -26,3 +27,19 @@ export const buildUserSaveValidation = (isSelf = false) => {
|
|||
}
|
||||
return joiValidator.body(Joi.object(schema).required().unknown(true))
|
||||
}
|
||||
|
||||
export const buildUserBulkSaveValidation = (isSelf = false) => {
|
||||
if (!isSelf) {
|
||||
schema = {
|
||||
...schema,
|
||||
_id: Joi.string(),
|
||||
_rev: Joi.string(),
|
||||
}
|
||||
}
|
||||
let bulkSaveSchema = {
|
||||
groups: Joi.array().optional(),
|
||||
users: Joi.array().items(Joi.object(schema).required().unknown(true)),
|
||||
}
|
||||
|
||||
return joiValidator.body(Joi.object(bulkSaveSchema).required().unknown(true))
|
||||
}
|
||||
|
|
|
@ -15,11 +15,12 @@ import {
|
|||
accounts,
|
||||
migrations,
|
||||
} from "@budibase/backend-core"
|
||||
import { MigrationType } from "@budibase/types"
|
||||
import { MigrationType, User } from "@budibase/types"
|
||||
import { groups as groupUtils } from "@budibase/pro"
|
||||
|
||||
const PAGE_LIMIT = 8
|
||||
|
||||
export const allUsers = async () => {
|
||||
export const allUsers = async (newDb?: any) => {
|
||||
const db = tenancy.getGlobalDB()
|
||||
const response = await db.allDocs(
|
||||
dbUtils.getGlobalUserParams(null, {
|
||||
|
@ -31,8 +32,9 @@ export const allUsers = async () => {
|
|||
|
||||
export const paginatedUsers = async ({
|
||||
page,
|
||||
search,
|
||||
}: { page?: string; search?: string } = {}) => {
|
||||
email,
|
||||
appId,
|
||||
}: { page?: string; email?: string; appId?: string } = {}) => {
|
||||
const db = tenancy.getGlobalDB()
|
||||
// get one extra document, to have the next page
|
||||
const opts: any = {
|
||||
|
@ -44,19 +46,24 @@ export const paginatedUsers = async ({
|
|||
opts.startkey = page
|
||||
}
|
||||
// property specifies what to use for the page/anchor
|
||||
let userList, property
|
||||
// no search, query allDocs
|
||||
if (!search) {
|
||||
let userList,
|
||||
property = "_id",
|
||||
getKey
|
||||
if (appId) {
|
||||
userList = await usersCore.searchGlobalUsersByApp(appId, opts)
|
||||
getKey = (doc: any) => usersCore.getGlobalUserByAppPage(appId, doc)
|
||||
} else if (email) {
|
||||
userList = await usersCore.searchGlobalUsersByEmail(email, opts)
|
||||
property = "email"
|
||||
} else {
|
||||
// no search, query allDocs
|
||||
const response = await db.allDocs(dbUtils.getGlobalUserParams(null, opts))
|
||||
userList = response.rows.map((row: any) => row.doc)
|
||||
property = "_id"
|
||||
} else {
|
||||
userList = await usersCore.searchGlobalUsersByEmail(search, opts)
|
||||
property = "email"
|
||||
}
|
||||
return dbUtils.pagination(userList, PAGE_LIMIT, {
|
||||
paginate: true,
|
||||
property,
|
||||
getKey,
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -87,6 +94,49 @@ interface SaveUserOpts {
|
|||
bulkCreate?: boolean
|
||||
}
|
||||
|
||||
export const buildUser = async (
|
||||
user: any,
|
||||
opts: SaveUserOpts = {
|
||||
hashPassword: true,
|
||||
requirePassword: true,
|
||||
bulkCreate: false,
|
||||
},
|
||||
tenantId: string,
|
||||
dbUser?: any
|
||||
) => {
|
||||
let { password, _id } = user
|
||||
|
||||
let hashedPassword
|
||||
if (password) {
|
||||
hashedPassword = opts.hashPassword ? await utils.hash(password) : password
|
||||
} else if (dbUser) {
|
||||
hashedPassword = dbUser.password
|
||||
} else if (opts.requirePassword) {
|
||||
throw "Password must be specified."
|
||||
}
|
||||
|
||||
_id = _id || dbUtils.generateGlobalUserID()
|
||||
|
||||
user = {
|
||||
createdAt: Date.now(),
|
||||
...dbUser,
|
||||
...user,
|
||||
_id,
|
||||
password: hashedPassword,
|
||||
tenantId,
|
||||
}
|
||||
// make sure the roles object is always present
|
||||
if (!user.roles) {
|
||||
user.roles = {}
|
||||
}
|
||||
// add the active status to a user if its not provided
|
||||
if (user.status == null) {
|
||||
user.status = constants.UserStatus.ACTIVE
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
export const save = async (
|
||||
user: any,
|
||||
opts: SaveUserOpts = {
|
||||
|
@ -97,7 +147,7 @@ export const save = async (
|
|||
) => {
|
||||
const tenantId = tenancy.getTenantId()
|
||||
const db = tenancy.getGlobalDB()
|
||||
let { email, password, _id } = user
|
||||
let { email, _id } = user
|
||||
// make sure another user isn't using the same email
|
||||
let dbUser: any
|
||||
if (opts.bulkCreate) {
|
||||
|
@ -128,36 +178,19 @@ export const save = async (
|
|||
dbUser = await db.get(_id)
|
||||
}
|
||||
|
||||
// get the password, make sure one is defined
|
||||
let hashedPassword
|
||||
if (password) {
|
||||
hashedPassword = opts.hashPassword ? await utils.hash(password) : password
|
||||
} else if (dbUser) {
|
||||
hashedPassword = dbUser.password
|
||||
} else if (opts.requirePassword) {
|
||||
throw "Password must be specified."
|
||||
}
|
||||
|
||||
_id = _id || dbUtils.generateGlobalUserID()
|
||||
user = {
|
||||
createdAt: Date.now(),
|
||||
...dbUser,
|
||||
...user,
|
||||
_id,
|
||||
password: hashedPassword,
|
||||
let builtUser = await buildUser(
|
||||
user,
|
||||
{
|
||||
hashPassword: true,
|
||||
requirePassword: user.requirePassword,
|
||||
},
|
||||
tenantId,
|
||||
}
|
||||
// make sure the roles object is always present
|
||||
if (!user.roles) {
|
||||
user.roles = {}
|
||||
}
|
||||
// add the active status to a user if its not provided
|
||||
if (user.status == null) {
|
||||
user.status = constants.UserStatus.ACTIVE
|
||||
}
|
||||
dbUser
|
||||
)
|
||||
|
||||
try {
|
||||
const putOpts = {
|
||||
password: hashedPassword,
|
||||
password: builtUser.password,
|
||||
...user,
|
||||
}
|
||||
if (opts.bulkCreate) {
|
||||
|
@ -166,28 +199,21 @@ export const save = async (
|
|||
// save the user to db
|
||||
let response
|
||||
const putUserFn = () => {
|
||||
return db.put(user)
|
||||
return db.put(builtUser)
|
||||
}
|
||||
if (eventHelpers.isAddingBuilder(user, dbUser)) {
|
||||
|
||||
if (eventHelpers.isAddingBuilder(builtUser, dbUser)) {
|
||||
response = await quotas.addDeveloper(putUserFn)
|
||||
} else {
|
||||
response = await putUserFn()
|
||||
}
|
||||
user._rev = response.rev
|
||||
builtUser._rev = response.rev
|
||||
|
||||
await eventHelpers.handleSaveEvents(user, dbUser)
|
||||
|
||||
if (env.MULTI_TENANCY) {
|
||||
const afterCreateTenant = () =>
|
||||
migrations.backPopulateMigrations({
|
||||
type: MigrationType.GLOBAL,
|
||||
tenantId,
|
||||
})
|
||||
await tenancy.tryAddTenant(tenantId, _id, email, afterCreateTenant)
|
||||
}
|
||||
await eventHelpers.handleSaveEvents(builtUser, dbUser)
|
||||
await addTenant(tenantId, _id, email)
|
||||
await cache.user.invalidateUser(response.id)
|
||||
// let server know to sync user
|
||||
await apps.syncUserInApps(user._id)
|
||||
await apps.syncUserInApps(builtUser._id)
|
||||
|
||||
return {
|
||||
_id: response.id,
|
||||
|
@ -203,9 +229,143 @@ export const save = async (
|
|||
}
|
||||
}
|
||||
|
||||
export const addTenant = async (
|
||||
tenantId: string,
|
||||
_id: string,
|
||||
email: string
|
||||
) => {
|
||||
if (env.MULTI_TENANCY) {
|
||||
const afterCreateTenant = () =>
|
||||
migrations.backPopulateMigrations({
|
||||
type: MigrationType.GLOBAL,
|
||||
tenantId,
|
||||
})
|
||||
await tenancy.tryAddTenant(tenantId, _id, email, afterCreateTenant)
|
||||
}
|
||||
}
|
||||
|
||||
export const bulkCreate = async (
|
||||
newUsersRequested: User[],
|
||||
groups: string[]
|
||||
) => {
|
||||
const db = tenancy.getGlobalDB()
|
||||
const tenantId = tenancy.getTenantId()
|
||||
|
||||
let usersToSave: any[] = []
|
||||
let newUsers: any[] = []
|
||||
|
||||
const allUsers = await db.allDocs(
|
||||
dbUtils.getGlobalUserParams(null, {
|
||||
include_docs: true,
|
||||
})
|
||||
)
|
||||
let mapped = allUsers.rows.map((row: any) => row.id)
|
||||
|
||||
const currentUserEmails = mapped.map((x: any) => x.email) || []
|
||||
for (const newUser of newUsersRequested) {
|
||||
if (
|
||||
newUsers.find((x: any) => x.email === newUser.email) ||
|
||||
currentUserEmails.includes(newUser.email)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
newUser.userGroups = groups
|
||||
newUsers.push(newUser)
|
||||
}
|
||||
|
||||
// Figure out how many builders we are adding and create the promises
|
||||
// array that will be called by bulkDocs
|
||||
let builderCount = 0
|
||||
newUsers.forEach((user: any) => {
|
||||
if (eventHelpers.isAddingBuilder(user, null)) {
|
||||
builderCount++
|
||||
}
|
||||
usersToSave.push(
|
||||
buildUser(
|
||||
user,
|
||||
{
|
||||
hashPassword: true,
|
||||
requirePassword: user.requirePassword,
|
||||
bulkCreate: false,
|
||||
},
|
||||
tenantId
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
const usersToBulkSave = await Promise.all(usersToSave)
|
||||
await quotas.addDevelopers(() => db.bulkDocs(usersToBulkSave), builderCount)
|
||||
|
||||
// Post processing of bulk added users, i.e events and cache operations
|
||||
for (const user of usersToBulkSave) {
|
||||
await eventHelpers.handleSaveEvents(user, null)
|
||||
await apps.syncUserInApps(user._id)
|
||||
}
|
||||
|
||||
return usersToBulkSave.map(user => {
|
||||
return {
|
||||
_id: user._id,
|
||||
email: user.email,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const bulkDelete = async (userIds: any) => {
|
||||
const db = tenancy.getGlobalDB()
|
||||
|
||||
let groupsToModify: any = {}
|
||||
let builderCount = 0
|
||||
// Get users and delete
|
||||
let usersToDelete = (
|
||||
await db.allDocs({
|
||||
include_docs: true,
|
||||
keys: userIds,
|
||||
})
|
||||
).rows.map((user: any) => {
|
||||
// if we find a user that has an associated group, add it to
|
||||
// an array so we can easily use allDocs on them later.
|
||||
// This prevents us having to re-loop over all the users
|
||||
if (user.doc.userGroups) {
|
||||
for (let groupId of user.doc.userGroups) {
|
||||
if (!Object.keys(groupsToModify).includes(groupId)) {
|
||||
groupsToModify[groupId] = [user.id]
|
||||
} else {
|
||||
groupsToModify[groupId] = [...groupsToModify[groupId], user.id]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also figure out how many builders are being deleted
|
||||
if (eventHelpers.isAddingBuilder(user.doc, null)) {
|
||||
builderCount++
|
||||
}
|
||||
|
||||
return user.doc
|
||||
})
|
||||
|
||||
const response = await db.bulkDocs(
|
||||
usersToDelete.map((user: any) => ({
|
||||
...user,
|
||||
_deleted: true,
|
||||
}))
|
||||
)
|
||||
|
||||
await groupUtils.bulkDeleteGroupUsers(groupsToModify)
|
||||
|
||||
//Deletion post processing
|
||||
for (let user of usersToDelete) {
|
||||
await bulkDeleteProcessing(user)
|
||||
}
|
||||
|
||||
await quotas.removeDevelopers(builderCount)
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
export const destroy = async (id: string, currentUser: any) => {
|
||||
const db = tenancy.getGlobalDB()
|
||||
const dbUser = await db.get(id)
|
||||
let groups = dbUser.userGroups
|
||||
|
||||
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
|
||||
// root account holder can't be deleted from inside budibase
|
||||
|
@ -221,7 +381,13 @@ export const destroy = async (id: string, currentUser: any) => {
|
|||
}
|
||||
|
||||
await deprovisioning.removeUserFromInfoDB(dbUser)
|
||||
|
||||
await db.remove(dbUser._id, dbUser._rev)
|
||||
|
||||
if (groups) {
|
||||
await groupUtils.deleteGroupUsers(groups, dbUser)
|
||||
}
|
||||
|
||||
await eventHelpers.handleDeleteEvents(dbUser)
|
||||
await quotas.removeUser(dbUser)
|
||||
await cache.user.invalidateUser(dbUser._id)
|
||||
|
@ -229,3 +395,12 @@ export const destroy = async (id: string, currentUser: any) => {
|
|||
// let server know to sync user
|
||||
await apps.syncUserInApps(dbUser._id)
|
||||
}
|
||||
|
||||
const bulkDeleteProcessing = async (dbUser: User) => {
|
||||
await deprovisioning.removeUserFromInfoDB(dbUser)
|
||||
await eventHelpers.handleDeleteEvents(dbUser)
|
||||
await cache.user.invalidateUser(dbUser._id)
|
||||
await sessions.invalidateSessions(dbUser._id)
|
||||
// let server know to sync user
|
||||
await apps.syncUserInApps(dbUser._id)
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ const { createASession } = require("@budibase/backend-core/sessions")
|
|||
const { TENANT_ID, CSRF_TOKEN } = require("./structures")
|
||||
const structures = require("./structures")
|
||||
const { doInTenant } = require("@budibase/backend-core/tenancy")
|
||||
|
||||
const { groups } = require("@budibase/pro")
|
||||
class TestConfiguration {
|
||||
constructor(openServer = true) {
|
||||
if (openServer) {
|
||||
|
@ -116,6 +116,22 @@ class TestConfiguration {
|
|||
})
|
||||
}
|
||||
|
||||
async getGroup(id) {
|
||||
return doInTenant(TENANT_ID, () => {
|
||||
return groups.get(id)
|
||||
})
|
||||
}
|
||||
|
||||
async saveGroup(group) {
|
||||
const res = await this.getRequest()
|
||||
.post(`/api/global/groups`)
|
||||
.send(group)
|
||||
.set(this.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
return res.body
|
||||
}
|
||||
|
||||
async createUser(email, password) {
|
||||
const user = await this.getUser(structures.users.email)
|
||||
if (user) {
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue