Merge pull request #12443 from Budibase/creator-changes

Creator changes
This commit is contained in:
Martin McKeaveney 2023-11-26 21:46:52 +00:00 committed by GitHub
commit a7c8097fbf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 356 additions and 249 deletions

View File

@ -160,4 +160,5 @@ export function isPermissionLevelHigherThanRead(level: PermissionLevel) {
// utility as a lot of things need simply the builder permission // utility as a lot of things need simply the builder permission
export const BUILDER = PermissionType.BUILDER export const BUILDER = PermissionType.BUILDER
export const CREATOR = PermissionType.CREATOR
export const GLOBAL_BUILDER = PermissionType.GLOBAL_BUILDER export const GLOBAL_BUILDER = PermissionType.GLOBAL_BUILDER

View File

@ -146,12 +146,12 @@ export class UserDB {
static async allUsers() { static async allUsers() {
const db = getGlobalDB() const db = getGlobalDB()
const response = await db.allDocs( const response = await db.allDocs<User>(
dbUtils.getGlobalUserParams(null, { dbUtils.getGlobalUserParams(null, {
include_docs: true, include_docs: true,
}) })
) )
return response.rows.map((row: any) => row.doc) return response.rows.map(row => row.doc!)
} }
static async countUsersByApp(appId: string) { static async countUsersByApp(appId: string) {
@ -209,13 +209,6 @@ export class UserDB {
throw new Error("_id or email is required") throw new Error("_id or email is required")
} }
if (
user.builder?.apps?.length &&
!(await UserDB.features.isAppBuildersEnabled())
) {
throw new Error("Unable to update app builders, please check license")
}
let dbUser: User | undefined let dbUser: User | undefined
if (_id) { if (_id) {
// try to get existing user from db // try to get existing user from db

View File

@ -25,6 +25,7 @@ import {
import { getGlobalDB } from "../context" import { getGlobalDB } from "../context"
import * as context from "../context" import * as context from "../context"
import { isCreator } from "./utils" import { isCreator } from "./utils"
import { UserDB } from "./db"
type GetOpts = { cleanup?: boolean } type GetOpts = { cleanup?: boolean }
@ -336,3 +337,20 @@ export function cleanseUserObject(user: User | ContextUser, base?: User) {
} }
return user return user
} }
export async function addAppBuilder(user: User, appId: string) {
const prodAppId = getProdAppID(appId)
user.builder ??= {}
user.builder.creator = true
user.builder.apps ??= []
user.builder.apps.push(prodAppId)
await UserDB.save(user, { hashPassword: false })
}
export async function removeAppBuilder(user: User, appId: string) {
const prodAppId = getProdAppID(appId)
if (user.builder && user.builder.apps?.includes(prodAppId)) {
user.builder.apps = user.builder.apps.filter(id => id !== prodAppId)
}
await UserDB.save(user, { hashPassword: false })
}

View File

@ -2,7 +2,7 @@
import "@spectrum-css/buttongroup/dist/index-vars.css" import "@spectrum-css/buttongroup/dist/index-vars.css"
export let vertical = false export let vertical = false
export let gap = "" export let gap = "M"
$: gapStyle = $: gapStyle =
gap === "L" gap === "L"

View File

@ -12,11 +12,13 @@
export let error = null export let error = null
export let validate = null export let validate = null
export let options = [] export let options = []
export let footer = null
export let isOptionEnabled = () => true export let isOptionEnabled = () => true
export let getOptionLabel = option => extractProperty(option, "label") export let getOptionLabel = option => extractProperty(option, "label")
export let getOptionValue = option => extractProperty(option, "value") export let getOptionValue = option => extractProperty(option, "value")
export let getOptionSubtitle = option => extractProperty(option, "subtitle") export let getOptionSubtitle = option => extractProperty(option, "subtitle")
export let getOptionColour = () => null export let getOptionColour = () => null
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let open = false let open = false
@ -100,6 +102,7 @@
{error} {error}
{disabled} {disabled}
{options} {options}
{footer}
{getOptionLabel} {getOptionLabel}
{getOptionValue} {getOptionValue}
{getOptionSubtitle} {getOptionSubtitle}

View File

@ -17,7 +17,7 @@
export let options = [] export let options = []
export let getOptionLabel = option => extractProperty(option, "label") export let getOptionLabel = option => extractProperty(option, "label")
export let getOptionValue = option => extractProperty(option, "value") export let getOptionValue = option => extractProperty(option, "value")
export let getOptionSubtitle = option => option?.subtitle
export let isOptionSelected = () => false export let isOptionSelected = () => false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -135,7 +135,7 @@
class="spectrum-Textfield-input spectrum-InputGroup-input" class="spectrum-Textfield-input spectrum-InputGroup-input"
/> />
</div> </div>
<div style="width: 30%"> <div style="width: 40%">
<button <button
{id} {id}
class="spectrum-Picker spectrum-Picker--sizeM override-borders" class="spectrum-Picker spectrum-Picker--sizeM override-borders"
@ -157,38 +157,43 @@
<use xlink:href="#spectrum-css-icon-Chevron100" /> <use xlink:href="#spectrum-css-icon-Chevron100" />
</svg> </svg>
</button> </button>
{#if open}
<div
use:clickOutside={handleOutsideClick}
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>
{#if open}
<div
use:clickOutside={handleOutsideClick}
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)}
{#if getOptionSubtitle(option, idx)}
<span class="subtitle-text">
{getOptionSubtitle(option, idx)}
</span>
{/if}
</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> <style>
@ -196,7 +201,6 @@
min-width: 0; min-width: 0;
width: 100%; width: 100%;
} }
.spectrum-InputGroup-input { .spectrum-InputGroup-input {
border-right-width: 1px; border-right-width: 1px;
} }
@ -206,7 +210,6 @@
.spectrum-Textfield-input { .spectrum-Textfield-input {
width: 0; width: 0;
} }
.override-borders { .override-borders {
border-top-left-radius: 0px; border-top-left-radius: 0px;
border-bottom-left-radius: 0px; border-bottom-left-radius: 0px;
@ -215,5 +218,18 @@
max-height: 240px; max-height: 240px;
z-index: 999; z-index: 999;
top: 100%; top: 100%;
width: 100%;
}
.subtitle-text {
font-size: 12px;
line-height: 15px;
font-weight: 500;
color: var(--spectrum-global-color-gray-600);
display: block;
margin-top: var(--spacing-s);
}
.spectrum-Menu-checkmark {
align-self: center;
margin-top: 0;
} }
</style> </style>

View File

@ -224,13 +224,12 @@
</span> </span>
{/if} {/if}
<span class="spectrum-Menu-itemLabel"> <span class="spectrum-Menu-itemLabel">
{#if getOptionSubtitle(option, idx)}
<span class="subtitle-text"
>{getOptionSubtitle(option, idx)}</span
>
{/if}
{getOptionLabel(option, idx)} {getOptionLabel(option, idx)}
{#if getOptionSubtitle(option, idx)}
<span class="subtitle-text">
{getOptionSubtitle(option, idx)}
</span>
{/if}
</span> </span>
{#if option.tag} {#if option.tag}
<span class="option-tag"> <span class="option-tag">
@ -275,10 +274,9 @@
font-size: 12px; font-size: 12px;
line-height: 15px; line-height: 15px;
font-weight: 500; font-weight: 500;
top: 10px;
color: var(--spectrum-global-color-gray-600); color: var(--spectrum-global-color-gray-600);
display: block; display: block;
margin-bottom: var(--spacing-s); margin-top: var(--spacing-s);
} }
.spectrum-Picker-label.auto-width { .spectrum-Picker-label.auto-width {

View File

@ -10,8 +10,9 @@
export let getOptionLabel = option => option export let getOptionLabel = option => option
export let getOptionValue = option => option export let getOptionValue = option => option
export let getOptionIcon = () => null export let getOptionIcon = () => null
export let useOptionIconImage = false
export let getOptionColour = () => null export let getOptionColour = () => null
export let getOptionSubtitle = () => null
export let useOptionIconImage = false
export let isOptionEnabled export let isOptionEnabled
export let readonly = false export let readonly = false
export let quiet = false export let quiet = false
@ -82,8 +83,9 @@
{getOptionLabel} {getOptionLabel}
{getOptionValue} {getOptionValue}
{getOptionIcon} {getOptionIcon}
{useOptionIconImage}
{getOptionColour} {getOptionColour}
{getOptionSubtitle}
{useOptionIconImage}
{isOptionEnabled} {isOptionEnabled}
{autocomplete} {autocomplete}
{sort} {sort}

View File

@ -43,6 +43,7 @@
{quiet} {quiet}
{autofocus} {autofocus}
{options} {options}
isOptionSelected={option => option === dropdownValue}
on:change={onChange} on:change={onChange}
on:pick={onPick} on:pick={onPick}
on:click on:click

View File

@ -13,9 +13,10 @@
export let options = [] export let options = []
export let getOptionLabel = option => extractProperty(option, "label") export let getOptionLabel = option => extractProperty(option, "label")
export let getOptionValue = option => extractProperty(option, "value") export let getOptionValue = option => extractProperty(option, "value")
export let getOptionSubtitle = option => option?.subtitle
export let getOptionIcon = option => option?.icon export let getOptionIcon = option => option?.icon
export let useOptionIconImage = false
export let getOptionColour = option => option?.colour export let getOptionColour = option => option?.colour
export let useOptionIconImage = false
export let isOptionEnabled export let isOptionEnabled
export let quiet = false export let quiet = false
export let autoWidth = false export let autoWidth = false
@ -58,6 +59,7 @@
{getOptionValue} {getOptionValue}
{getOptionIcon} {getOptionIcon}
{getOptionColour} {getOptionColour}
{getOptionSubtitle}
{useOptionIconImage} {useOptionIconImage}
{isOptionEnabled} {isOptionEnabled}
{autocomplete} {autocomplete}

View File

@ -20,73 +20,91 @@
export let allowedRoles = null export let allowedRoles = null
export let allowCreator = false export let allowCreator = false
export let fancySelect = false export let fancySelect = false
export let labelPrefix = null
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const RemoveID = "remove" const RemoveID = "remove"
$: enrichLabel = label => (labelPrefix ? `${labelPrefix} ${label}` : label)
$: options = getOptions( $: options = getOptions(
$roles, $roles,
allowPublic, allowPublic,
allowRemove, allowRemove,
allowedRoles, allowedRoles,
allowCreator allowCreator,
enrichLabel
) )
const getOptions = ( const getOptions = (
roles, roles,
allowPublic, allowPublic,
allowRemove, allowRemove,
allowedRoles, allowedRoles,
allowCreator allowCreator,
enrichLabel
) => { ) => {
// Use roles whitelist if specified
if (allowedRoles?.length) { if (allowedRoles?.length) {
const filteredRoles = roles.filter(role => let options = roles
allowedRoles.includes(role._id) .filter(role => allowedRoles.includes(role._id))
) .map(role => ({
return [ name: enrichLabel(role.name),
...filteredRoles, _id: role._id,
...(allowedRoles.includes(Constants.Roles.CREATOR) }))
? [{ _id: Constants.Roles.CREATOR, name: "Creator", enabled: false }] if (allowedRoles.includes(Constants.Roles.CREATOR)) {
: []), options.push({
]
}
let newRoles = [...roles]
if (allowCreator) {
newRoles = [
{
_id: Constants.Roles.CREATOR, _id: Constants.Roles.CREATOR,
name: "Creator", name: "Can edit",
tag: enabled: false,
!$licensing.perAppBuildersEnabled && })
capitalise(Constants.PlanType.BUSINESS), }
}, return options
...newRoles,
]
} }
// Allow all core roles
let options = roles.map(role => ({
name: enrichLabel(role.name),
_id: role._id,
}))
// Add creator if required
if (allowCreator) {
options.unshift({
_id: Constants.Roles.CREATOR,
name: "Can edit",
tag:
!$licensing.perAppBuildersEnabled &&
capitalise(Constants.PlanType.BUSINESS),
})
}
// Add remove option if required
if (allowRemove) { if (allowRemove) {
newRoles = [ options.push({
...newRoles, _id: RemoveID,
{ name: "Remove",
_id: RemoveID, })
name: "Remove",
},
]
} }
if (allowPublic) {
return newRoles // Remove public if not allowed
if (!allowPublic) {
options = options.filter(role => role._id !== Constants.Roles.PUBLIC)
} }
return newRoles.filter(role => role._id !== Constants.Roles.PUBLIC)
return options
} }
const getColor = role => { const getColor = role => {
if (allowRemove && role._id === RemoveID) { // Creator and remove options have no colors
if (role._id === Constants.Roles.CREATOR || role._id === RemoveID) {
return null return null
} }
return RoleUtils.getRoleColour(role._id) return RoleUtils.getRoleColour(role._id)
} }
const getIcon = role => { const getIcon = role => {
if (allowRemove && role._id === RemoveID) { // Only remove option has an icon
if (role._id === RemoveID) {
return "Close" return "Close"
} }
return null return null

View File

@ -364,7 +364,10 @@
const payload = [ const payload = [
{ {
email: newUserEmail, email: newUserEmail,
builder: { global: creationRoleType === Constants.BudibaseRoles.Admin }, builder: {
global: creationRoleType === Constants.BudibaseRoles.Admin,
creator: creationRoleType === Constants.BudibaseRoles.Creator,
},
admin: { global: creationRoleType === Constants.BudibaseRoles.Admin }, admin: { global: creationRoleType === Constants.BudibaseRoles.Admin },
}, },
] ]
@ -471,10 +474,6 @@
await users.removeAppBuilder(userId, prodAppId) await users.removeAppBuilder(userId, prodAppId)
} }
const addGroupAppBuilder = async groupId => {
await groups.actions.addGroupAppBuilder(groupId, prodAppId)
}
const removeGroupAppBuilder = async groupId => { const removeGroupAppBuilder = async groupId => {
await groups.actions.removeGroupAppBuilder(groupId, prodAppId) await groups.actions.removeGroupAppBuilder(groupId, prodAppId)
} }
@ -495,14 +494,12 @@
} }
const getInviteRoleValue = invite => { const getInviteRoleValue = invite => {
if (invite.info?.admin?.global && invite.info?.builder?.global) { if (
return Constants.Roles.ADMIN (invite.info?.admin?.global && invite.info?.builder?.global) ||
} invite.info?.builder?.apps?.includes(prodAppId)
) {
if (invite.info?.builder?.apps?.includes(prodAppId)) {
return Constants.Roles.CREATOR return Constants.Roles.CREATOR
} }
return invite.info.apps?.[prodAppId] return invite.info.apps?.[prodAppId]
} }
@ -512,7 +509,7 @@
return `This user has been given ${role?.name} access from the ${user.group} group` return `This user has been given ${role?.name} access from the ${user.group} group`
} }
if (user.isAdminOrGlobalBuilder) { if (user.isAdminOrGlobalBuilder) {
return "This user's role grants admin access to all apps" return "Account admins can edit all apps"
} }
return null return null
} }
@ -523,6 +520,18 @@
} }
return user.role return user.role
} }
const checkAppAccess = e => {
// Ensure we don't get into an invalid combo of tenant role and app access
if (
e.detail === Constants.BudibaseRoles.AppUser &&
creationAccessType === Constants.Roles.CREATOR
) {
creationAccessType = Constants.Roles.BASIC
} else if (e.detail === Constants.BudibaseRoles.Admin) {
creationAccessType = Constants.Roles.CREATOR
}
}
</script> </script>
<svelte:window on:keydown={handleKeyDown} /> <svelte:window on:keydown={handleKeyDown} />
@ -650,8 +659,9 @@
autoWidth autoWidth
align="right" align="right"
allowedRoles={user.isAdminOrGlobalBuilder allowedRoles={user.isAdminOrGlobalBuilder
? [Constants.Roles.ADMIN] ? [Constants.Roles.CREATOR]
: null} : null}
labelPrefix="Can use as"
/> />
</div> </div>
</div> </div>
@ -695,19 +705,16 @@
allowRemove={group.role} allowRemove={group.role}
allowPublic={false} allowPublic={false}
quiet={true} quiet={true}
allowCreator={true} allowCreator={group.role === Constants.Roles.CREATOR}
on:change={e => { on:change={e => {
if (e.detail === Constants.Roles.CREATOR) { onUpdateGroup(group, e.detail)
addGroupAppBuilder(group._id)
} else {
onUpdateGroup(group, e.detail)
}
}} }}
on:remove={() => { on:remove={() => {
onUpdateGroup(group) onUpdateGroup(group)
}} }}
autoWidth autoWidth
align="right" align="right"
labelPrefix="Can use as"
/> />
</div> </div>
</div> </div>
@ -753,6 +760,7 @@
allowedRoles={user.isAdminOrGlobalBuilder allowedRoles={user.isAdminOrGlobalBuilder
? [Constants.Roles.CREATOR] ? [Constants.Roles.CREATOR]
: null} : null}
labelPrefix="Can use as"
/> />
</div> </div>
</div> </div>
@ -804,33 +812,34 @@
<FancySelect <FancySelect
bind:value={creationRoleType} bind:value={creationRoleType}
options={sdk.users.isAdmin($auth.user) options={sdk.users.isAdmin($auth.user)
? Constants.BudibaseRoleOptionsNew ? Constants.BudibaseRoleOptions
: Constants.BudibaseRoleOptionsNew.filter( : Constants.BudibaseRoleOptions.filter(
option => option.value !== Constants.BudibaseRoles.Admin option => option.value !== Constants.BudibaseRoles.Admin
)} )}
label="Access" label="Role"
on:change={checkAppAccess}
/> />
{#if creationRoleType !== Constants.BudibaseRoles.Admin} <span class="role-wrap">
<span class="role-wrap"> <RoleSelect
<RoleSelect placeholder={false}
placeholder={false} bind:value={creationAccessType}
bind:value={creationAccessType} allowPublic={false}
allowPublic={false} allowCreator={creationRoleType !==
allowCreator={true} Constants.BudibaseRoles.AppUser}
quiet={true} quiet={true}
autoWidth autoWidth
align="right" align="right"
fancySelect fancySelect
/> allowedRoles={creationRoleType === Constants.BudibaseRoles.Admin
</span> ? [Constants.Roles.CREATOR]
{/if} : null}
footer={getRoleFooter({
isAdminOrGlobalBuilder:
creationRoleType === Constants.BudibaseRoles.Admin,
})}
/>
</span>
</FancyForm> </FancyForm>
{#if creationRoleType === Constants.BudibaseRoles.Admin}
<div class="admin-info">
<Icon name="Info" />
Admins will get full access to all apps and settings
</div>
{/if}
<span class="add-user"> <span class="add-user">
<Button <Button
newStyles newStyles
@ -871,16 +880,6 @@
display: grid; display: grid;
} }
.admin-info {
margin-top: var(--spacing-xl);
padding: var(--spacing-l) var(--spacing-l) var(--spacing-l) var(--spacing-l);
display: flex;
align-items: center;
gap: var(--spacing-xl);
height: 30px;
background-color: var(--background-alt);
}
.underlined { .underlined {
text-decoration: underline; text-decoration: underline;
cursor: pointer; cursor: pointer;
@ -898,7 +897,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--spacing-s); gap: var(--spacing-s);
width: 400px;
} }
.auth-entity-meta { .auth-entity-meta {
@ -927,7 +925,7 @@
.auth-entity, .auth-entity,
.auth-entity-header { .auth-entity-header {
display: grid; display: grid;
grid-template-columns: 1fr 110px; grid-template-columns: 1fr 180px;
align-items: center; align-items: center;
gap: var(--spacing-xl); gap: var(--spacing-xl);
} }
@ -958,7 +956,7 @@
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
position: absolute; position: absolute;
width: 400px; width: 440px;
right: 0; right: 0;
height: 100%; height: 100%;
box-shadow: 0 0 40px 10px rgba(0, 0, 0, 0.1); box-shadow: 0 0 40px 10px rgba(0, 0, 0, 0.1);

View File

@ -4,8 +4,6 @@
import { url, isActive } from "@roxi/routify" import { url, isActive } from "@roxi/routify"
import DeleteModal from "components/deploy/DeleteModal.svelte" import DeleteModal from "components/deploy/DeleteModal.svelte"
import { isOnlyUser } from "builderStore" import { isOnlyUser } from "builderStore"
import { auth } from "stores/portal"
import { sdk } from "@budibase/shared-core"
let deleteModal let deleteModal
</script> </script>
@ -46,24 +44,22 @@
url={$url("./version")} url={$url("./version")}
active={$isActive("./version")} active={$isActive("./version")}
/> />
{#if sdk.users.isGlobalBuilder($auth.user)} <div class="delete-action">
<div class="delete-action"> <AbsTooltip
<AbsTooltip position={TooltipPosition.Bottom}
position={TooltipPosition.Bottom} text={$isOnlyUser
text={$isOnlyUser ? null
? null : "Unavailable - another user is editing this app"}
: "Unavailable - another user is editing this app"} >
> <SideNavItem
<SideNavItem text="Delete app"
text="Delete app" disabled={!$isOnlyUser}
disabled={!$isOnlyUser} on:click={() => {
on:click={() => { deleteModal.show()
deleteModal.show() }}
}} />
/> </AbsTooltip>
</AbsTooltip> </div>
</div>
{/if}
</SideNav> </SideNav>
<slot /> <slot />
</Content> </Content>

View File

@ -16,7 +16,7 @@
let activeTab = "Apps" let activeTab = "Apps"
$: $url(), updateActiveTab($menu) $: $url(), updateActiveTab($menu)
$: isOnboarding = !$apps.length && sdk.users.isGlobalBuilder($auth.user) $: isOnboarding = !$apps.length && sdk.users.hasBuilderPermissions($auth.user)
const updateActiveTab = menu => { const updateActiveTab = menu => {
for (let entry of menu) { for (let entry of menu) {

View File

@ -34,7 +34,7 @@
} }
// Go to new app page if no apps exists // Go to new app page if no apps exists
if (!$apps.length && sdk.users.isGlobalBuilder($auth.user)) { if (!$apps.length && sdk.users.hasBuilderPermissions($auth.user)) {
$redirect("./onboarding") $redirect("./onboarding")
} }
} catch (error) { } catch (error) {

View File

@ -237,7 +237,7 @@
{#if enrichedApps.length} {#if enrichedApps.length}
<Layout noPadding gap="L"> <Layout noPadding gap="L">
<div class="title"> <div class="title">
{#if $auth.user && sdk.users.isGlobalBuilder($auth.user)} {#if $auth.user && sdk.users.canCreateApps($auth.user)}
<div class="buttons"> <div class="buttons">
<Button <Button
size="M" size="M"

View File

@ -52,7 +52,7 @@
goToApp() goToApp()
} catch (e) { } catch (e) {
loading = false loading = false
notifications.error("There was a problem creating your app") notifications.error(e.message || "There was a problem creating your app")
} }
} }
</script> </script>

View File

@ -55,6 +55,7 @@
}, },
role: { role: {
width: "1fr", width: "1fr",
displayName: "Access",
}, },
} }
const customGroupTableRenderers = [ const customGroupTableRenderers = [
@ -98,7 +99,7 @@
return y._id === userId return y._id === userId
}) })
}) })
$: globalRole = sdk.users.isAdmin(user) ? "admin" : "appUser" $: globalRole = users.getUserRole(user)
const getAvailableApps = (appList, privileged, roles) => { const getAvailableApps = (appList, privileged, roles) => {
let availableApps = appList.slice() let availableApps = appList.slice()
@ -177,12 +178,21 @@
} }
async function updateUserRole({ detail }) { async function updateUserRole({ detail }) {
if (detail === "developer") { if (detail === Constants.BudibaseRoles.Developer) {
toggleFlags({ admin: { global: false }, builder: { global: true } }) toggleFlags({ admin: { global: false }, builder: { global: true } })
} else if (detail === "admin") { } else if (detail === Constants.BudibaseRoles.Admin) {
toggleFlags({ admin: { global: true }, builder: { global: true } }) toggleFlags({ admin: { global: true }, builder: { global: true } })
} else if (detail === "appUser") { } else if (detail === Constants.BudibaseRoles.AppUser) {
toggleFlags({ admin: { global: false }, builder: { global: false } }) toggleFlags({ admin: { global: false }, builder: { global: false } })
} else if (detail === Constants.BudibaseRoles.Creator) {
toggleFlags({
admin: { global: false },
builder: {
global: false,
creator: true,
apps: user?.builder?.apps || [],
},
})
} }
} }
@ -295,6 +305,7 @@
<div class="field"> <div class="field">
<Label size="L">Role</Label> <Label size="L">Role</Label>
<Select <Select
placeholder={null}
disabled={!sdk.users.isAdmin($auth.user)} disabled={!sdk.users.isAdmin($auth.user)}
value={globalRole} value={globalRole}
options={Constants.BudibaseRoleOptions} options={Constants.BudibaseRoleOptions}

View File

@ -29,7 +29,6 @@
}, },
] ]
$: hasError = userData.find(x => x.error != null) $: hasError = userData.find(x => x.error != null)
$: userCount = $licensing.userCount + userData.length $: userCount = $licensing.userCount + userData.length
$: reached = licensing.usersLimitReached(userCount) $: reached = licensing.usersLimitReached(userCount)
$: exceeded = licensing.usersLimitExceeded(userCount) $: exceeded = licensing.usersLimitExceeded(userCount)
@ -98,7 +97,7 @@
align-items: center; align-items: center;
flex-direction: row;" flex-direction: row;"
> >
<div style="width: 90%"> <div style="flex: 1 1 auto;">
<InputDropdown <InputDropdown
inputType="email" inputType="email"
bind:inputValue={input.email} bind:inputValue={input.email}

View File

@ -14,6 +14,10 @@
} }
</script> </script>
<StatusLight square color={RoleUtils.getRoleColour(value)}> {#if value === Constants.Roles.CREATOR}
{getRoleLabel(value)} Can edit
</StatusLight> {:else}
<StatusLight square color={RoleUtils.getRoleColour(value)}>
Can use as {getRoleLabel(value)}
</StatusLight>
{/if}

View File

@ -15,6 +15,7 @@
const BYTES_IN_MB = 1000000 const BYTES_IN_MB = 1000000
const FILE_SIZE_LIMIT = BYTES_IN_MB * 5 const FILE_SIZE_LIMIT = BYTES_IN_MB * 5
const MAX_USERS_UPLOAD_LIMIT = 1000 const MAX_USERS_UPLOAD_LIMIT = 1000
export let createUsersFromCsv export let createUsersFromCsv
let files = [] let files = []
@ -22,13 +23,16 @@
let userEmails = [] let userEmails = []
let userGroups = [] let userGroups = []
let usersRole = null let usersRole = null
$: invalidEmails = []
$: invalidEmails = []
$: userCount = $licensing.userCount + userEmails.length $: userCount = $licensing.userCount + userEmails.length
$: exceed = licensing.usersLimitExceeded(userCount) $: exceed = licensing.usersLimitExceeded(userCount)
$: importDisabled = $: importDisabled =
!userEmails.length || !validEmails(userEmails) || !usersRole || exceed !userEmails.length || !validEmails(userEmails) || !usersRole || exceed
$: roleOptions = Constants.BudibaseRoleOptions.map(option => ({
...option,
label: `${option.label} - ${option.subtitle}`,
}))
const validEmails = userEmails => { const validEmails = userEmails => {
if ($admin.cloud && userEmails.length > MAX_USERS_UPLOAD_LIMIT) { if ($admin.cloud && userEmails.length > MAX_USERS_UPLOAD_LIMIT) {
@ -100,10 +104,7 @@
users. Upgrade your plan to add more users users. Upgrade your plan to add more users
</div> </div>
{/if} {/if}
<RadioGroup <RadioGroup bind:value={usersRole} options={roleOptions} />
bind:value={usersRole}
options={Constants.BuilderRoleDescriptions}
/>
{#if $licensing.groupsEnabled} {#if $licensing.groupsEnabled}
<Multiselect <Multiselect

View File

@ -4,17 +4,11 @@
export let row export let row
const TooltipMap = { $: role = Constants.BudibaseRoleOptions.find(
appUser: "Only has access to assigned apps",
developer: "Access to the app builder",
admin: "Full access",
}
$: role = Constants.BudibaseRoleOptionsOld.find(
x => x.value === users.getUserRole(row) x => x.value === users.getUserRole(row)
) )
$: value = role?.label || "Not available" $: value = role?.label || "Not available"
$: tooltip = TooltipMap[role?.value] || "" $: tooltip = role.subtitle || ""
</script> </script>
<div on:click|stopPropagation title={tooltip}> <div on:click|stopPropagation title={tooltip}>

View File

@ -172,6 +172,7 @@
const payload = userData?.users?.map(user => ({ const payload = userData?.users?.map(user => ({
email: user.email, email: user.email,
builder: user.role === Constants.BudibaseRoles.Developer, builder: user.role === Constants.BudibaseRoles.Developer,
creator: user.role === Constants.BudibaseRoles.Creator,
admin: user.role === Constants.BudibaseRoles.Admin, admin: user.role === Constants.BudibaseRoles.Admin,
groups: userData.groups, groups: userData.groups,
})) }))
@ -190,18 +191,18 @@
for (const user of userData?.users ?? []) { for (const user of userData?.users ?? []) {
const { email } = user const { email } = user
if ( if (
newUsers.find(x => x.email === email) || newUsers.find(x => x.email === email) ||
currentUserEmails.includes(email) currentUserEmails.includes(email)
) ) {
continue continue
}
newUsers.push(user) newUsers.push(user)
} }
if (!newUsers.length) if (!newUsers.length) {
notifications.info("Duplicated! There is no new users to add.") notifications.info("Duplicated! There is no new users to add.")
}
return { ...userData, users: newUsers } return { ...userData, users: newUsers }
} }
@ -266,7 +267,6 @@
try { try {
await groups.actions.init() await groups.actions.init()
groupsLoaded = true groupsLoaded = true
pendingInvites = await users.getInvites() pendingInvites = await users.getInvites()
invitesLoaded = true invitesLoaded = true
} catch (error) { } catch (error) {

View File

@ -3,6 +3,7 @@ import { API } from "api"
import { update } from "lodash" import { update } from "lodash"
import { licensing } from "." import { licensing } from "."
import { sdk } from "@budibase/shared-core" import { sdk } from "@budibase/shared-core"
import { Constants } from "@budibase/frontend-core"
export function createUsersStore() { export function createUsersStore() {
const { subscribe, set } = writable({}) const { subscribe, set } = writable({})
@ -77,6 +78,9 @@ export function createUsersStore() {
case "developer": case "developer":
body.builder = { global: true } body.builder = { global: true }
break break
case "creator":
body.builder = { creator: true, global: false }
break
case "admin": case "admin":
body.admin = { global: true } body.admin = { global: true }
body.builder = { global: true } body.builder = { global: true }
@ -120,12 +124,18 @@ export function createUsersStore() {
return await API.removeAppBuilder({ userId, appId }) return await API.removeAppBuilder({ userId, appId })
} }
const getUserRole = user => const getUserRole = user => {
sdk.users.isAdmin(user) if (sdk.users.isAdmin(user)) {
? "admin" return Constants.BudibaseRoles.Admin
: sdk.users.isBuilder(user) } else if (sdk.users.isBuilder(user)) {
? "developer" return Constants.BudibaseRoles.Developer
: "appUser" } else if (sdk.users.hasCreatorPermissions(user)) {
return Constants.BudibaseRoles.Creator
} else {
return Constants.BudibaseRoles.AppUser
}
}
const refreshUsage = const refreshUsage =
fn => fn =>
async (...args) => { async (...args) => {

View File

@ -214,15 +214,23 @@ export const buildUserEndpoints = API => ({
inviteUsers: async users => { inviteUsers: async users => {
return await API.post({ return await API.post({
url: "/api/global/users/multi/invite", url: "/api/global/users/multi/invite",
body: users.map(user => ({ body: users.map(user => {
email: user.email, let builder = undefined
userInfo: { if (user.admin || user.builder) {
admin: user.admin ? { global: true } : undefined, builder = { global: true }
builder: user.admin || user.builder ? { global: true } : undefined, } else if (user.creator) {
userGroups: user.groups, builder = { creator: true }
roles: user.apps ? user.apps : undefined, }
}, return {
})), email: user.email,
userInfo: {
admin: user.admin ? { global: true } : undefined,
builder,
userGroups: user.groups,
roles: user.apps ? user.apps : undefined,
},
}
}),
}) })
}, },

View File

@ -20,42 +20,31 @@ export const TableNames = {
export const BudibaseRoles = { export const BudibaseRoles = {
AppUser: "appUser", AppUser: "appUser",
Developer: "developer", Developer: "developer",
Creator: "creator",
Admin: "admin", Admin: "admin",
} }
export const BudibaseRoleOptionsOld = [ export const BudibaseRoleOptionsOld = [
{ label: "Developer", value: BudibaseRoles.Developer }, {
{ label: "Member", value: BudibaseRoles.AppUser }, label: "Developer",
{ label: "Admin", value: BudibaseRoles.Admin }, value: BudibaseRoles.Developer,
},
] ]
export const BudibaseRoleOptions = [ export const BudibaseRoleOptions = [
{ label: "Member", value: BudibaseRoles.AppUser },
{ label: "Admin", value: BudibaseRoles.Admin },
]
export const BudibaseRoleOptionsNew = [
{ {
label: "Admin", label: "Account admin",
value: "admin", value: BudibaseRoles.Admin,
subtitle: "Has full access to all apps and settings in your account", subtitle: "Has full access to all apps and settings in your account",
}, },
{ {
label: "Member", label: "Creator",
value: "appUser", value: BudibaseRoles.Creator,
subtitle: "Can only view apps they have access to", subtitle: "Can create and edit apps they have access to",
}, },
]
export const BuilderRoleDescriptions = [
{ {
label: "App user",
value: BudibaseRoles.AppUser, value: BudibaseRoles.AppUser,
icon: "User", subtitle: "Can only use published apps they have access to",
label: "App user - Only has access to published apps",
},
{
value: BudibaseRoles.Admin,
icon: "Draw",
label: "Admin - Full access",
}, },
] ]

View File

@ -51,6 +51,7 @@ import {
import { BASE_LAYOUT_PROP_IDS } from "../../constants/layouts" import { BASE_LAYOUT_PROP_IDS } from "../../constants/layouts"
import sdk from "../../sdk" import sdk from "../../sdk"
import { builderSocket } from "../../websockets" import { builderSocket } from "../../websockets"
import { sdk as sharedCoreSDK } from "@budibase/shared-core"
// utility function, need to do away with this // utility function, need to do away with this
async function getLayouts() { async function getLayouts() {
@ -394,6 +395,12 @@ async function appPostCreate(ctx: UserCtx, app: App) {
} }
} }
} }
// If the user is a creator, we need to give them access to the new app
if (sharedCoreSDK.users.hasCreatorPermissions(ctx.user)) {
const user = await users.UserDB.getUser(ctx.user._id!)
await users.addAppBuilder(user, app.appId)
}
} }
export async function create(ctx: UserCtx) { export async function create(ctx: UserCtx) {

View File

@ -16,7 +16,7 @@ router
) )
.post( .post(
"/api/applications", "/api/applications",
authorized(permissions.GLOBAL_BUILDER), authorized(permissions.CREATOR),
applicationValidator(), applicationValidator(),
controller.create controller.create
) )

View File

@ -5,7 +5,7 @@ import {
roles, roles,
users, users,
} from "@budibase/backend-core" } from "@budibase/backend-core"
import { PermissionLevel, PermissionType, Role, UserCtx } from "@budibase/types" import { PermissionLevel, PermissionType, UserCtx } from "@budibase/types"
import builderMiddleware from "./builder" import builderMiddleware from "./builder"
import { isWebhookEndpoint } from "./utils" import { isWebhookEndpoint } from "./utils"
import { paramResource } from "./resourceId" import { paramResource } from "./resourceId"
@ -31,13 +31,20 @@ const checkAuthorized = async (
) => { ) => {
const appId = context.getAppId() const appId = context.getAppId()
const isGlobalBuilderApi = permType === PermissionType.GLOBAL_BUILDER const isGlobalBuilderApi = permType === PermissionType.GLOBAL_BUILDER
const isCreatorApi = permType === PermissionType.CREATOR
const isBuilderApi = permType === PermissionType.BUILDER const isBuilderApi = permType === PermissionType.BUILDER
const globalBuilder = users.isGlobalBuilder(ctx.user) const isGlobalBuilder = users.isGlobalBuilder(ctx.user)
let isBuilder = appId const isCreator = users.isCreator(ctx.user)
const isBuilder = appId
? users.isBuilder(ctx.user, appId) ? users.isBuilder(ctx.user, appId)
: users.hasBuilderPermissions(ctx.user) : users.hasBuilderPermissions(ctx.user)
// check if this is a builder api and the user is not a builder
if ((isGlobalBuilderApi && !globalBuilder) || (isBuilderApi && !isBuilder)) { // check api permission type against user
if (
(isGlobalBuilderApi && !isGlobalBuilder) ||
(isCreatorApi && !isCreator) ||
(isBuilderApi && !isBuilder)
) {
return ctx.throw(403, "Not Authorized") return ctx.throw(403, "Not Authorized")
} }
@ -148,6 +155,7 @@ const authorized =
// to find API endpoints which are builder focused // to find API endpoints which are builder focused
if ( if (
permType === PermissionType.BUILDER || permType === PermissionType.BUILDER ||
permType === PermissionType.CREATOR ||
permType === PermissionType.GLOBAL_BUILDER permType === PermissionType.GLOBAL_BUILDER
) { ) {
await builderMiddleware(ctx) await builderMiddleware(ctx)

View File

@ -25,6 +25,10 @@ export function isGlobalBuilder(user: User | ContextUser): boolean {
return (isBuilder(user) && !hasAppBuilderPermissions(user)) || isAdmin(user) return (isBuilder(user) && !hasAppBuilderPermissions(user)) || isAdmin(user)
} }
export function canCreateApps(user: User | ContextUser): boolean {
return isGlobalBuilder(user) || hasCreatorPermissions(user)
}
// alias for hasAdminPermission, currently do the same thing // alias for hasAdminPermission, currently do the same thing
// in future whether someone has admin permissions and whether they are // in future whether someone has admin permissions and whether they are
// an admin for a specific resource could be separated // an admin for a specific resource could be separated
@ -66,7 +70,7 @@ export function hasAppCreatorPermissions(user?: User | ContextUser): boolean {
return _.flow( return _.flow(
_.get("roles"), _.get("roles"),
_.values, _.values,
_.find(x => ["CREATOR", "ADMIN"].includes(x)), _.find(x => x === "CREATOR"),
x => !!x x => !!x
)(user) )(user)
} }
@ -76,7 +80,11 @@ export function hasBuilderPermissions(user?: User | ContextUser): boolean {
if (!user) { if (!user) {
return false return false
} }
return user.builder?.global || hasAppBuilderPermissions(user) return (
user.builder?.global ||
hasAppBuilderPermissions(user) ||
hasCreatorPermissions(user)
)
} }
// checks if a user is capable of being an admin // checks if a user is capable of being an admin
@ -87,13 +95,21 @@ export function hasAdminPermissions(user?: User | ContextUser): boolean {
return !!user.admin?.global return !!user.admin?.global
} }
export function hasCreatorPermissions(user?: User | ContextUser): boolean {
if (!user) {
return false
}
return !!user.builder?.creator
}
export function isCreator(user?: User | ContextUser): boolean { export function isCreator(user?: User | ContextUser): boolean {
if (!user) { if (!user) {
return false return false
} }
return ( return (
isGlobalBuilder(user) || isGlobalBuilder(user!) ||
hasAdminPermissions(user) || hasAdminPermissions(user) ||
hasCreatorPermissions(user) ||
hasAppBuilderPermissions(user) || hasAppBuilderPermissions(user) ||
hasAppCreatorPermissions(user) hasAppCreatorPermissions(user)
) )

View File

@ -44,6 +44,7 @@ export interface User extends Document {
builder?: { builder?: {
global?: boolean global?: boolean
apps?: string[] apps?: string[]
creator?: boolean
} }
admin?: { admin?: {
global: boolean global: boolean

View File

@ -13,6 +13,7 @@ export enum PermissionType {
AUTOMATION = "automation", AUTOMATION = "automation",
WEBHOOK = "webhook", WEBHOOK = "webhook",
BUILDER = "builder", BUILDER = "builder",
CREATOR = "creator",
GLOBAL_BUILDER = "globalBuilder", GLOBAL_BUILDER = "globalBuilder",
QUERY = "query", QUERY = "query",
VIEW = "view", VIEW = "view",

View File

@ -51,10 +51,22 @@ export async function removeAppRole(ctx: Ctx) {
const users = await sdk.users.db.allUsers() const users = await sdk.users.db.allUsers()
const bulk = [] const bulk = []
const cacheInvalidations = [] const cacheInvalidations = []
const prodAppId = dbCore.getProdAppID(appId)
for (let user of users) { for (let user of users) {
if (user.roles[appId]) { let updated = false
cacheInvalidations.push(cache.user.invalidateUser(user._id)) if (user.roles[prodAppId]) {
delete user.roles[appId] cacheInvalidations.push(cache.user.invalidateUser(user._id!))
delete user.roles[prodAppId]
updated = true
}
if (user.builder && Array.isArray(user.builder?.apps)) {
const idx = user.builder.apps.indexOf(prodAppId)
if (idx !== -1) {
user.builder.apps.splice(idx, 1)
updated = true
}
}
if (updated) {
bulk.push(user) bulk.push(user)
} }
} }