From b4c88bd545b1edea79f95deded40b35ae96c86e1 Mon Sep 17 00:00:00 2001 From: Dean Date: Thu, 23 Feb 2023 10:38:03 +0000 Subject: [PATCH 01/15] Merge commit to dev --- packages/types/src/api/web/user.ts | 1 + .../src/api/controllers/global/users.ts | 119 +++++++++++++++++- packages/worker/src/sdk/users/users.ts | 6 +- packages/worker/src/utilities/redis.ts | 40 ++++++ 4 files changed, 159 insertions(+), 7 deletions(-) diff --git a/packages/types/src/api/web/user.ts b/packages/types/src/api/web/user.ts index 6acaf6912d..18f98048c4 100644 --- a/packages/types/src/api/web/user.ts +++ b/packages/types/src/api/web/user.ts @@ -16,6 +16,7 @@ export interface BulkUserRequest { userIds: string[] } create?: { + roles?: any[] users: User[] groups: any[] } diff --git a/packages/worker/src/api/controllers/global/users.ts b/packages/worker/src/api/controllers/global/users.ts index 817480151d..f8cb4b2103 100644 --- a/packages/worker/src/api/controllers/global/users.ts +++ b/packages/worker/src/api/controllers/global/users.ts @@ -1,4 +1,8 @@ -import { checkInviteCode } from "../../../utilities/redis" +import { + checkInviteCode, + getInviteCodes, + updateInviteCode, +} from "../../../utilities/redis" import sdk from "../../../sdk" import env from "../../../environment" import { @@ -6,7 +10,6 @@ import { BulkUserResponse, CloudAccount, CreateAdminUserRequest, - InviteUserRequest, InviteUsersRequest, SearchUsersRequest, User, @@ -19,6 +22,7 @@ import { tenancy, } from "@budibase/backend-core" import { checkAnyUserExists } from "../../../utilities/users" +import { isEmailConfigured } from "src/utilities/email" const MAX_USERS_UPLOAD_LIMIT = 1000 @@ -186,9 +190,54 @@ export const tenantUserLookup = async (ctx: any) => { } } +/* + Encapsulate the app user onboarding flows here. +*/ +export const onboardUsers = async (ctx: any) => { + const request = ctx.request.body as InviteUsersRequest | BulkUserRequest + const isBulkCreate = "create" in request + + const emailConfigured = await isEmailConfigured() + + let onboardingResponse + + if (isBulkCreate) { + // @ts-ignore + const { users, groups, roles } = request.create + const assignUsers = users.map((user: User) => (user.roles = roles)) + onboardingResponse = await sdk.users.bulkCreate(assignUsers, groups) + ctx.body = onboardingResponse + } else if (emailConfigured) { + onboardingResponse = await invite(ctx) + } else if (!emailConfigured) { + const inviteRequest = ctx.request.body as InviteUsersRequest + const users: User[] = inviteRequest.map(invite => { + let password = Math.random().toString(36).substring(2, 22) + + return { + email: invite.email, + password, + forceResetPassword: true, + roles: invite.userInfo.apps, + admin: { global: false }, + builder: { global: false }, + tenantId: tenancy.getTenantId(), + } + }) + let bulkCreateReponse = await sdk.users.bulkCreate(users, []) + onboardingResponse = { + ...bulkCreateReponse, + created: true, + } + ctx.body = onboardingResponse + } else { + ctx.throw(400, "User onboarding failed") + } +} + export const invite = async (ctx: any) => { - const request = ctx.request.body as InviteUserRequest - const response = await sdk.users.invite([request]) + const request = ctx.request.body as InviteUsersRequest + const response = await sdk.users.invite(request) // explicitly throw for single user invite if (response.unsuccessful.length) { @@ -202,6 +251,8 @@ export const invite = async (ctx: any) => { ctx.body = { message: "Invitation has been sent.", + successful: response.successful, + unsuccessful: response.unsuccessful, } } @@ -223,19 +274,75 @@ export const checkInvite = async (ctx: any) => { } } +export const getUserInvites = async (ctx: any) => { + let invites + try { + // Restricted to the currently authenticated tenant + invites = await getInviteCodes([ctx.user.tenantId]) + } catch (e) { + ctx.throw(400, "There was a problem fetching invites") + } + ctx.body = invites +} + +export const updateInvite = async (ctx: any) => { + const { code } = ctx.params + let updateBody = { ...ctx.request.body } + + delete updateBody.email + + let invite + try { + invite = await checkInviteCode(code, false) + if (!invite) { + throw new Error("The invite could not be retrieved") + } + } catch (e) { + ctx.throw(400, "There was a problem with the invite") + } + + let updated = { + ...invite, + } + + if (!updateBody?.apps || !Object.keys(updateBody?.apps).length) { + updated.info.apps = [] + } else { + updated.info = { + ...invite.info, + apps: { + ...invite.info.apps, + ...updateBody.apps, + }, + } + } + + await updateInviteCode(code, updated) + ctx.body = { ...invite } +} + export const inviteAccept = async (ctx: any) => { const { inviteCode, password, firstName, lastName } = ctx.request.body try { // info is an extension of the user object that was stored by global const { email, info }: any = await checkInviteCode(inviteCode) ctx.body = await tenancy.doInTenant(info.tenantId, async () => { - const saved = await sdk.users.save({ + let request = { firstName, lastName, password, email, + roles: info.apps, + } + + delete info.apps + + request = { + ...request, ...info, - }) + } + + const saved = await sdk.users.save(request) const db = tenancy.getGlobalDB() const user = await db.get(saved._id) await events.user.inviteAccepted(user) diff --git a/packages/worker/src/sdk/users/users.ts b/packages/worker/src/sdk/users/users.ts index 5124a5c5b1..db49e801c3 100644 --- a/packages/worker/src/sdk/users/users.ts +++ b/packages/worker/src/sdk/users/users.ts @@ -203,7 +203,7 @@ export const save = async ( const tenantId = tenancy.getTenantId() const db = tenancy.getGlobalDB() - let { email, _id, userGroups = [] } = user + let { email, _id, userGroups = [], roles } = user if (!email && !_id) { throw new Error("_id or email is required") @@ -245,6 +245,10 @@ export const save = async ( builtUser.roles = dbUser.roles } + if (!dbUser && roles?.length) { + builtUser.roles = { ...roles } + } + // make sure we set the _id field for a new user // Also if this is a new user, associate groups with them let groupPromises = [] diff --git a/packages/worker/src/utilities/redis.ts b/packages/worker/src/utilities/redis.ts index 893ec9f0a8..a05b043054 100644 --- a/packages/worker/src/utilities/redis.ts +++ b/packages/worker/src/utilities/redis.ts @@ -29,6 +29,20 @@ async function writeACode(db: string, value: any) { return code } +async function updateACode(db: string, code: string, value: any) { + const client = await getClient(db) + await client.store(code, value, getExpirySecondsForDB(db)) +} + +/** + * Given an invite code and invite body, allow the update an existing/valid invite in redis + * @param {string} inviteCode The invite code for an invite in redis + * @param {object} value The body of the updated user invitation + */ +export async function updateInviteCode(inviteCode: string, value: string) { + await updateACode(redis.utils.Databases.INVITATIONS, inviteCode, value) +} + async function getACode(db: string, code: string, deleteCode = true) { const client = await getClient(db) const value = await client.get(code) @@ -111,3 +125,29 @@ export async function checkInviteCode( throw "Invitation is not valid or has expired, please request a new one." } } + +/** + Get all currently available user invitations. + @return {Object[]} A +**/ +export async function getInviteCodes( + tenantIds?: string[] //should default to the current tenant of the user session. +) { + const client = await getClient(redis.utils.Databases.INVITATIONS) + const invites: any[] = await client.scan() + + const results = invites.map(invite => { + return { + ...invite.value, + code: invite.key, + } + }) + return results.reduce((acc, invite) => { + if (tenantIds?.length && tenantIds.includes(invite.info.tenantId)) { + acc.push(invite) + } else { + acc.push(invite) + } + return acc + }, []) +} From 32619fbfa33ac883298d3db46aefeed86e3bb033 Mon Sep 17 00:00:00 2001 From: Dean Date: Mon, 27 Feb 2023 09:11:32 +0000 Subject: [PATCH 02/15] Merge commit --- packages/builder/src/builderStore/store/frontend.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/builder/src/builderStore/store/frontend.js b/packages/builder/src/builderStore/store/frontend.js index 56b8a599f0..7b01f57a53 100644 --- a/packages/builder/src/builderStore/store/frontend.js +++ b/packages/builder/src/builderStore/store/frontend.js @@ -67,6 +67,8 @@ const INITIAL_FRONTEND_STATE = { // onboarding onboarding: false, tourNodes: null, + + builderSidePanel: false, } export const getFrontendStore = () => { From 61ed62e6c41deb1c92b9edd9c2d6c930125c01d0 Mon Sep 17 00:00:00 2001 From: Dean Date: Tue, 28 Feb 2023 09:37:03 +0000 Subject: [PATCH 03/15] Builder user onboarding --- packages/backend-core/src/users.ts | 41 + .../bbui/src/ActionButton/ActionButton.svelte | 98 ++- packages/bbui/src/Form/Core/Select.svelte | 4 +- ...loyNavigation.svelte => AppActions.svelte} | 170 ++-- .../src/components/deploy/RevertModal.svelte | 12 +- .../portal/onboarding/TourPopover.svelte | 4 +- .../src/components/portal/onboarding/tours.js | 24 + .../_components/BuilderSidePanel.svelte | 735 ++++++++++++++++++ .../builder/app/[application]/_layout.svelte | 22 +- packages/builder/src/stores/portal/users.js | 17 + packages/frontend-core/src/api/user.js | 52 +- packages/frontend-core/src/fetch/UserFetch.js | 1 + packages/types/src/api/web/user.ts | 2 +- .../src/api/controllers/global/users.ts | 49 +- .../worker/src/api/routes/global/users.ts | 29 +- packages/worker/src/sdk/users/users.ts | 13 +- packages/worker/src/utilities/redis.ts | 6 +- 17 files changed, 1133 insertions(+), 146 deletions(-) rename packages/builder/src/components/deploy/{DeployNavigation.svelte => AppActions.svelte} (53%) create mode 100644 packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte 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() From 862ba6ce924a09726c791f451c749196166ad813 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Tue, 28 Feb 2023 12:29:13 +0000 Subject: [PATCH 04/15] Update spacing, borders and sizing --- packages/bbui/src/Form/Core/Picker.svelte | 4 +- packages/bbui/src/Form/Core/Select.svelte | 2 + packages/bbui/src/Form/Select.svelte | 2 + .../src/components/common/RoleSelect.svelte | 4 + .../_components/BuilderSidePanel.svelte | 356 ++++++++++-------- 5 files changed, 202 insertions(+), 166 deletions(-) diff --git a/packages/bbui/src/Form/Core/Picker.svelte b/packages/bbui/src/Form/Core/Picker.svelte index 5cef0f9213..4e7bd7ba78 100644 --- a/packages/bbui/src/Form/Core/Picker.svelte +++ b/packages/bbui/src/Form/Core/Picker.svelte @@ -33,6 +33,8 @@ export let sort = false export let fetchTerm = null export let customPopoverHeight + export let align = "left" + const dispatch = createEventDispatcher() let searchTerm = null @@ -131,7 +133,7 @@ (open = false)} diff --git a/packages/bbui/src/Form/Core/Select.svelte b/packages/bbui/src/Form/Core/Select.svelte index aa9071607e..a1ae2107c8 100644 --- a/packages/bbui/src/Form/Core/Select.svelte +++ b/packages/bbui/src/Form/Core/Select.svelte @@ -18,6 +18,7 @@ export let autoWidth = false export let autocomplete = false export let sort = false + export let align const dispatch = createEventDispatcher() @@ -66,6 +67,7 @@ {fieldColour} {options} {autoWidth} + {align} {getOptionLabel} {getOptionValue} {getOptionIcon} diff --git a/packages/bbui/src/Form/Select.svelte b/packages/bbui/src/Form/Select.svelte index 76fe613c92..44ce51eff2 100644 --- a/packages/bbui/src/Form/Select.svelte +++ b/packages/bbui/src/Form/Select.svelte @@ -22,6 +22,7 @@ export let tooltip = "" export let autocomplete = false export let customPopoverHeight + export let align const dispatch = createEventDispatcher() const onChange = e => { @@ -48,6 +49,7 @@ {placeholder} {autoWidth} {sort} + {align} {getOptionLabel} {getOptionValue} {getOptionIcon} diff --git a/packages/builder/src/components/common/RoleSelect.svelte b/packages/builder/src/components/common/RoleSelect.svelte index 645e82c8ba..f94f048945 100644 --- a/packages/builder/src/components/common/RoleSelect.svelte +++ b/packages/builder/src/components/common/RoleSelect.svelte @@ -11,6 +11,8 @@ export let quiet = false export let allowPublic = true export let allowRemove = false + export let disabled = false + export let align const dispatch = createEventDispatcher() const RemoveID = "remove" @@ -59,6 +61,8 @@