diff --git a/packages/backend-core/src/users.ts b/packages/backend-core/src/users.ts index ef76af390d..ad226e29d8 100644 --- a/packages/backend-core/src/users.ts +++ b/packages/backend-core/src/users.ts @@ -5,6 +5,7 @@ import { generateAppUserID, queryGlobalView, UNICODE_MAX, + directCouchFind, } from "./db" import { BulkDocsResponse, User } from "@budibase/types" import { getGlobalDB } from "./context" @@ -64,12 +65,52 @@ export const searchGlobalUsersByApp = async (appId: any, opts: any) => { }) params.startkey = opts && opts.startkey ? opts.startkey : params.startkey let response = await queryGlobalView(ViewName.USER_BY_APP, params) + if (!response) { response = [] } return Array.isArray(response) ? response : [response] } +/* + Return any user who potentially has access to the application + Admins, developers and app users with the explicitly role. +*/ +export const searchGlobalUsersByAppAccess = async (appId: any, opts: any) => { + const roleSelector = `roles.${appId}` + + let orQuery: any[] = [ + { + "builder.global": true, + }, + { + "admin.global": true, + }, + ] + + if (appId) { + const roleCheck = { + [roleSelector]: { + $exists: true, + }, + } + orQuery.push(roleCheck) + } + + let searchOptions = { + selector: { + $or: orQuery, + _id: { + $regex: "^us_", + }, + }, + limit: opts?.limit || 50, + } + + const resp = await directCouchFind(context.getGlobalDBName(), searchOptions) + return resp?.rows +} + export const getGlobalUserByAppPage = (appId: string, user: User) => { if (!user) { return diff --git a/packages/bbui/src/ActionButton/ActionButton.svelte b/packages/bbui/src/ActionButton/ActionButton.svelte index 663128160f..01f5033e6c 100644 --- a/packages/bbui/src/ActionButton/ActionButton.svelte +++ b/packages/bbui/src/ActionButton/ActionButton.svelte @@ -1,6 +1,9 @@ - + + diff --git a/packages/bbui/src/Form/Core/Select.svelte b/packages/bbui/src/Form/Core/Select.svelte index 721083e3a6..aa9071607e 100644 --- a/packages/bbui/src/Form/Core/Select.svelte +++ b/packages/bbui/src/Form/Core/Select.svelte @@ -41,7 +41,7 @@ const getFieldText = (value, options, placeholder) => { // Always use placeholder if no value if (value == null || value === "") { - return placeholder || "Choose an option" + return placeholder !== false ? "Choose an option" : "" } return getFieldAttribute(getOptionLabel, value, options) @@ -74,7 +74,7 @@ {autocomplete} {sort} isPlaceholder={value == null || value === ""} - placeholderOption={placeholder} + placeholderOption={placeholder === false ? null : placeholder} isOptionSelected={option => option === value} onSelectOption={selectOption} /> diff --git a/packages/builder/src/components/deploy/DeployNavigation.svelte b/packages/builder/src/components/deploy/AppActions.svelte similarity index 53% rename from packages/builder/src/components/deploy/DeployNavigation.svelte rename to packages/builder/src/components/deploy/AppActions.svelte index d3a428fed2..582b5ab8f0 100644 --- a/packages/builder/src/components/deploy/DeployNavigation.svelte +++ b/packages/builder/src/components/deploy/AppActions.svelte @@ -6,8 +6,10 @@ Heading, Body, Button, - Icon, + ActionButton, } from "@budibase/bbui" + import RevertModal from "components/deploy/RevertModal.svelte" + import VersionModal from "components/deploy/VersionModal.svelte" import { processStringSync } from "@budibase/string-templates" import ConfirmDialog from "components/common/ConfirmDialog.svelte" import analytics, { Events, EventSource } from "analytics" @@ -16,6 +18,8 @@ import { onMount } from "svelte" import DeployModal from "components/deploy/DeployModal.svelte" import { apps } from "stores/portal" + import { store } from "builderStore" + import TourWrap from "components/portal/onboarding/TourWrap.svelte" export let application @@ -108,66 +112,93 @@ }) -
- {#if isPublished} -
-
- -
- -
- - Your published app - - - {processStringSync( - "Last published {{ duration time 'millisecond' }} ago", - { - time: - new Date().getTime() - - new Date(latestDeployments[0].updatedAt).getTime(), - } - )} - - -
- - -
-
-
-
+
+
+
+
- {/if} + - {#if !isPublished} - - {/if} + {#if isPublished} +
+
+ +
+ +
+ + Your published app + + + {processStringSync( + "Last published {{ duration time 'millisecond' }} ago", + { + time: + new Date().getTime() - + new Date(latestDeployments[0].updatedAt).getTime(), + } + )} + + +
+ + +
+
+
+
+
+ {/if} + + {#if !isPublished} + + {/if} + + + + { + store.update(state => { + state.builderSidePanel = true + return state + }) + }} + > + Users + + + +
+ diff --git a/packages/builder/src/components/deploy/RevertModal.svelte b/packages/builder/src/components/deploy/RevertModal.svelte index a8f9d8f6e3..0c3372f8ec 100644 --- a/packages/builder/src/components/deploy/RevertModal.svelte +++ b/packages/builder/src/components/deploy/RevertModal.svelte @@ -1,10 +1,10 @@ - +
{tourStep?.title || "-"} -
{`${tourStepIdx + 1}/${tourSteps?.length}`}
+ {#if tourSteps?.length > 1} +
{`${tourStepIdx + 1}/${tourSteps?.length}`}
+ {/if}
diff --git a/packages/builder/src/components/portal/onboarding/tours.js b/packages/builder/src/components/portal/onboarding/tours.js index d1485c4872..7975f11bb5 100644 --- a/packages/builder/src/components/portal/onboarding/tours.js +++ b/packages/builder/src/components/portal/onboarding/tours.js @@ -9,11 +9,14 @@ export const TOUR_STEP_KEYS = { BUILDER_APP_PUBLISH: "builder-app-publish", BUILDER_DATA_SECTION: "builder-data-section", BUILDER_DESIGN_SECTION: "builder-design-section", + BUILDER_USER_MANAGEMENT: "builder-user-management", BUILDER_AUTOMATE_SECTION: "builder-automate-section", + FEATURE_USER_MANAGEMENT: "feature-user-management", } export const TOUR_KEYS = { TOUR_BUILDER_ONBOARDING: "builder-onboarding", + FEATURE_ONBOARDING: "feature-onboarding", } const tourEvent = eventKey => { @@ -58,6 +61,15 @@ const getTours = () => { }, align: "left", }, + { + id: TOUR_STEP_KEYS.BUILDER_USER_MANAGEMENT, + title: "Users", + query: ".toprightnav #builder-app-users-button", + body: "Choose which users you want to see to have access to your app and control what level of access they have.", + onLoad: () => { + tourEvent(TOUR_STEP_KEYS.BUILDER_USER_MANAGEMENT) + }, + }, { id: TOUR_STEP_KEYS.BUILDER_APP_PUBLISH, title: "Publish", @@ -90,6 +102,18 @@ const getTours = () => { }, }, ], + [TOUR_KEYS.FEATURE_ONBOARDING]: [ + { + id: TOUR_STEP_KEYS.FEATURE_USER_MANAGEMENT, + title: "Users", + query: ".toprightnav #builder-app-users-button", + body: "Choose which users you want to have access to your app and control what level of access they have.", + onLoad: () => { + tourEvent(TOUR_STEP_KEYS.FEATURE_USER_MANAGEMENT) + }, + align: "left", + }, + ], } } diff --git a/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte b/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte new file mode 100644 index 0000000000..ccd6071b90 --- /dev/null +++ b/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte @@ -0,0 +1,735 @@ + + +
{ + store.update(state => { + state.builderSidePanel = false + return state + }) + } + : () => {}} +> +
+ Users + { + store.update(state => { + state.builderSidePanel = false + return state + }) + }} + /> +
+ + + {#if promptInvite && !userOnboardResponse} + +
+ No user found +
+ Add a valid email to invite a new user +
+
+
+ {query || ""} + + Add user + +
+
+ {/if} + + {#if !promptInvite} + + {#if filteredInvites?.length} + +
+
Pending invites
+
Access
+
+ {#each filteredInvites as invite} +
+
+
+ {invite.email} +
+
+
+ { + onUpdateUserInvite(invite, e.detail) + }} + on:remove={() => { + onUninviteAppUser(invite) + }} + /> +
+
+ {/each} +
+ {/if} + + {#if $licensing.groupsEnabled && filteredGroups?.length} + +
+
Groups
+
Access
+
+ {#each filteredGroups as group} +
{ + if (selectedGroup != group._id) { + selectedGroup = group._id + } else { + selectedGroup = null + } + }} + on:keydown={() => {}} + > +
+ +
+ {group.name} +
+
+ {`${group.users?.length} user${ + group.users?.length != 1 ? "s" : "" + }`} +
+
+
+ { + onUpdateGroup(group, e.detail) + }} + on:remove={() => { + onUpdateGroup(group) + }} + /> +
+
+ {/each} +
+ {/if} + + {#if filteredUsers?.length} +
+
+
Users
+
Access
+
+ {#each allUsers as user} +
+
+
+ {user.email} +
+
+ {userTitle(user)} +
+
+
+ { + onUpdateUser(user, e.detail) + }} + on:remove={() => { + onUpdateUser(user) + }} + /> +
+
+ {/each} +
+ {/if} +
+ {/if} + + {#if userOnboardResponse?.created} + +
+ User added! +
+ Email invites are not available without SMTP configuration. Here is + the password that has been generated for this user. +
+
+
+ +
+
+ {/if} +
+ + diff --git a/packages/builder/src/pages/builder/app/[application]/_layout.svelte b/packages/builder/src/pages/builder/app/[application]/_layout.svelte index f561bd8ecd..abf70a84c0 100644 --- a/packages/builder/src/pages/builder/app/[application]/_layout.svelte +++ b/packages/builder/src/pages/builder/app/[application]/_layout.svelte @@ -13,15 +13,14 @@ notifications, } from "@budibase/bbui" - import RevertModal from "components/deploy/RevertModal.svelte" - import VersionModal from "components/deploy/VersionModal.svelte" - import DeployNavigation from "components/deploy/DeployNavigation.svelte" + import AppActions from "components/deploy/AppActions.svelte" import { API } from "api" import { isActive, goto, layout, redirect } from "@roxi/routify" import { capitalise } from "helpers" import { onMount, onDestroy } from "svelte" import TourWrap from "components/portal/onboarding/TourWrap.svelte" import TourPopover from "components/portal/onboarding/TourPopover.svelte" + import BuilderSidePanel from "./_components/BuilderSidePanel.svelte" import { TOUR_KEYS, TOURS } from "components/portal/onboarding/tours.js" export let application @@ -116,6 +115,11 @@
{:then _} + + {#if $store.builderSidePanel} + + {/if} +
@@ -181,11 +185,7 @@
-
- -
- - +
@@ -250,10 +250,6 @@ flex-direction: row; justify-content: flex-end; align-items: center; - gap: var(--spacing-xl); - } - - .version { - margin-right: var(--spacing-s); + gap: var(--spacing-l); } diff --git a/packages/builder/src/stores/portal/users.js b/packages/builder/src/stores/portal/users.js index 1510207604..206e14568b 100644 --- a/packages/builder/src/stores/portal/users.js +++ b/packages/builder/src/stores/portal/users.js @@ -26,9 +26,15 @@ export function createUsersStore() { return await API.getUsers() } + // One or more users. + async function onboard(payload) { + return await API.onboardUsers(payload) + } + async function invite(payload) { return API.inviteUsers(payload) } + async function acceptInvite(inviteCode, password, firstName, lastName) { return API.acceptInvite({ inviteCode, @@ -42,6 +48,14 @@ export function createUsersStore() { return API.getUserInvite(inviteCode) } + async function getInvites() { + return API.getUserInvites() + } + + async function updateInvite(invite) { + return API.updateUserInvite(invite) + } + async function create(data) { let mappedUsers = data.users.map(user => { const body = { @@ -106,8 +120,11 @@ export function createUsersStore() { getUserRole, fetch, invite, + onboard, acceptInvite, fetchInvite, + getInvites, + updateInvite, create, save, bulkDelete, diff --git a/packages/frontend-core/src/api/user.js b/packages/frontend-core/src/api/user.js index 9875605ce0..cb8a8f6555 100644 --- a/packages/frontend-core/src/api/user.js +++ b/packages/frontend-core/src/api/user.js @@ -12,8 +12,10 @@ export const buildUserEndpoints = API => ({ * Gets a list of users in the current tenant. * @param {string} page The page to retrieve * @param {string} search The starts with string to search username/email by. + * @param {string} appId Facilitate app/role based user searching + * @param {boolean} paginated Allow the disabling of pagination */ - searchUsers: async ({ page, email, appId } = {}) => { + searchUsers: async ({ paginated, page, email, appId } = {}) => { const opts = {} if (page) { opts.page = page @@ -24,6 +26,9 @@ export const buildUserEndpoints = API => ({ if (appId) { opts.appId = appId } + if (typeof paginated === "boolean") { + opts.paginated = paginated + } return await API.post({ url: `/api/global/users/search`, body: opts, @@ -133,7 +138,7 @@ export const buildUserEndpoints = API => ({ * @param builder whether the user should be a global builder * @param admin whether the user should be a global admin */ - inviteUser: async ({ email, builder, admin }) => { + inviteUser: async ({ email, builder, admin, apps }) => { return await API.post({ url: "/api/global/users/invite", body: { @@ -141,11 +146,43 @@ export const buildUserEndpoints = API => ({ userInfo: { admin: admin ? { global: true } : undefined, builder: builder ? { global: true } : undefined, + apps: apps ? apps : undefined, }, }, }) }, + onboardUsers: async payload => { + return await API.post({ + url: "/api/global/users/onboard", + body: payload.map(invite => { + const { email, admin, builder, apps } = invite + return { + email, + userInfo: { + admin: admin ? { global: true } : undefined, + builder: builder ? { global: true } : undefined, + apps: apps ? apps : undefined, + }, + } + }), + }) + }, + + /** + * Accepts a user invite as a body and will update the associated app roles. + * for an existing invite + * @param invite the invite code sent in the email + */ + updateUserInvite: async invite => { + await API.post({ + url: `/api/global/users/invite/update/${invite.code}`, + body: { + apps: invite.apps, + }, + }) + }, + /** * Retrieves the invitation associated with a provided code. * @param code The unique code for the target invite @@ -156,6 +193,16 @@ export const buildUserEndpoints = API => ({ }) }, + /** + * Retrieves the invitation associated with a provided code. + * @param code The unique code for the target invite + */ + getUserInvites: async () => { + return await API.get({ + url: `/api/global/users/invites`, + }) + }, + /** * Invites multiple users to the current tenant. * @param users An array of users to invite @@ -169,6 +216,7 @@ export const buildUserEndpoints = API => ({ admin: user.admin ? { global: true } : undefined, builder: user.admin || user.builder ? { global: true } : undefined, userGroups: user.groups, + roles: user.apps ? user.apps : undefined, }, })), }) diff --git a/packages/frontend-core/src/fetch/UserFetch.js b/packages/frontend-core/src/fetch/UserFetch.js index 9aeadbc0f5..5372d0ec33 100644 --- a/packages/frontend-core/src/fetch/UserFetch.js +++ b/packages/frontend-core/src/fetch/UserFetch.js @@ -35,6 +35,7 @@ export default class UserFetch extends DataFetch { page: cursor, email: query.email, appId: query.appId, + paginated: query.paginated, }) return { rows: res?.data || [], diff --git a/packages/types/src/api/web/user.ts b/packages/types/src/api/web/user.ts index b2ac6b7804..63d6be6fa0 100644 --- a/packages/types/src/api/web/user.ts +++ b/packages/types/src/api/web/user.ts @@ -50,7 +50,7 @@ export interface SearchUsersRequest { page?: string email?: string appId?: string - userIds?: string[] + paginated?: boolean } export interface CreateAdminUserRequest { diff --git a/packages/worker/src/api/controllers/global/users.ts b/packages/worker/src/api/controllers/global/users.ts index e41a280b22..2971011682 100644 --- a/packages/worker/src/api/controllers/global/users.ts +++ b/packages/worker/src/api/controllers/global/users.ts @@ -185,16 +185,28 @@ export const destroy = async (ctx: any) => { } } +export const getAppUsers = async (ctx: any) => { + const body = ctx.request.body as SearchUsersRequest + const users = await userSdk.getUsersByAppAccess(body?.appId) + + ctx.body = { data: users } +} + export const search = async (ctx: any) => { const body = ctx.request.body as SearchUsersRequest - const paginated = await userSdk.paginatedUsers(body) - // user hashed password shouldn't ever be returned - for (let user of paginated.data) { - if (user) { - delete user.password + + if (body.paginated === false) { + await getAppUsers(ctx) + } else { + const paginated = await userSdk.paginatedUsers(body) + // user hashed password shouldn't ever be returned + for (let user of paginated.data) { + if (user) { + delete user.password + } } + ctx.body = paginated } - ctx.body = paginated } // called internally by app server user fetch @@ -242,12 +254,18 @@ export const onboardUsers = async (ctx: any) => { onboardingResponse = await userSdk.bulkCreate(assignUsers, groups) ctx.body = onboardingResponse } else if (emailConfigured) { - onboardingResponse = await inviteMultiple(ctx) + onboardingResponse = await invite(ctx) } else if (!emailConfigured) { const inviteRequest = ctx.request.body as InviteUsersRequest + + let createdPasswords: any = {} + const users: User[] = inviteRequest.map(invite => { let password = Math.random().toString(36).substring(2, 22) + // Temp password to be passed to the user. + createdPasswords[invite.email] = password + return { email: invite.email, password, @@ -259,19 +277,28 @@ export const onboardUsers = async (ctx: any) => { } }) let bulkCreateReponse = await userSdk.bulkCreate(users, []) - onboardingResponse = { + + // Apply temporary credentials + let createWithCredentials = { ...bulkCreateReponse, + successful: bulkCreateReponse?.successful.map(user => { + return { + ...user, + password: createdPasswords[user.email], + } + }), created: true, } - ctx.body = onboardingResponse + + ctx.body = createWithCredentials } else { ctx.throw(400, "User onboarding failed") } } export const invite = async (ctx: any) => { - const request = ctx.request.body as InviteUserRequest - const response = await userSdk.invite([request]) + const request = ctx.request.body as InviteUsersRequest + const response = await userSdk.invite(request) // explicitly throw for single user invite if (response.unsuccessful.length) { diff --git a/packages/worker/src/api/routes/global/users.ts b/packages/worker/src/api/routes/global/users.ts index a73462b235..db182a99c6 100644 --- a/packages/worker/src/api/routes/global/users.ts +++ b/packages/worker/src/api/routes/global/users.ts @@ -38,13 +38,6 @@ function buildInviteMultipleValidation() { )) } -function buildInviteLookupValidation() { - // prettier-ignore - return auth.joiValidator.params(Joi.object({ - code: Joi.string().required() - }).unknown(true)) -} - const createUserAdminOnly = (ctx: any, next: any) => { if (!ctx.request.body._id) { return auth.adminOnly(ctx, next) @@ -88,22 +81,34 @@ router .get("/api/global/roles/:appId") .post( "/api/global/users/invite", - auth.adminOnly, + auth.builderOrAdmin, buildInviteValidation(), controller.invite ) + .post( + "/api/global/users/onboard", + auth.builderOrAdmin, + buildInviteMultipleValidation(), + controller.onboardUsers + ) .post( "/api/global/users/multi/invite", - auth.adminOnly, + auth.builderOrAdmin, buildInviteMultipleValidation(), controller.inviteMultiple ) // non-global endpoints + .get("/api/global/users/invite/:code", controller.checkInvite) + .post( + "/api/global/users/invite/update/:code", + auth.builderOrAdmin, + controller.updateInvite + ) .get( - "/api/global/users/invite/:code", - buildInviteLookupValidation(), - controller.checkInvite + "/api/global/users/invites", + auth.builderOrAdmin, + controller.getUserInvites ) .post( "/api/global/users/invite/accept", diff --git a/packages/worker/src/sdk/users/users.ts b/packages/worker/src/sdk/users/users.ts index 4a72badc10..de1ea27546 100644 --- a/packages/worker/src/sdk/users/users.ts +++ b/packages/worker/src/sdk/users/users.ts @@ -56,11 +56,22 @@ export const countUsersByApp = async (appId: string) => { } } +export const getUsersByAppAccess = async (appId?: string) => { + const opts: any = { + include_docs: true, + limit: 50, + } + let response: User[] = await usersCore.searchGlobalUsersByAppAccess( + appId, + opts + ) + return response +} + export const paginatedUsers = async ({ page, email, appId, - userIds, }: SearchUsersRequest = {}) => { const db = tenancy.getGlobalDB() // get one extra document, to have the next page diff --git a/packages/worker/src/utilities/redis.ts b/packages/worker/src/utilities/redis.ts index e9ba06227a..1e0d0beb97 100644 --- a/packages/worker/src/utilities/redis.ts +++ b/packages/worker/src/utilities/redis.ts @@ -130,11 +130,9 @@ export async function checkInviteCode( /** Get all currently available user invitations. - @return {Object[]} A + @return {Object[]} A list of all objects containing invite metadata **/ -export async function getInviteCodes( - tenantIds?: string[] //should default to the current tenant of the user session. -) { +export async function getInviteCodes(tenantIds?: string[]) { const client = await getClient(redis.utils.Databases.INVITATIONS) const invites: any[] = await client.scan()