diff --git a/packages/backend-core/src/users.ts b/packages/backend-core/src/users.ts index 9ef2c5c31f..8963f7c141 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" @@ -101,6 +102,7 @@ export const searchGlobalUsersByApp = async ( }) params.startkey = opts && opts.startkey ? opts.startkey : params.startkey let response = await queryGlobalView(ViewName.USER_BY_APP, params) + if (!response) { response = [] } @@ -111,6 +113,45 @@ export const searchGlobalUsersByApp = async ( return users } +/* + 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/Picker.svelte b/packages/bbui/src/Form/Core/Picker.svelte index 5cef0f9213..dbaa031ed0 100644 --- a/packages/bbui/src/Form/Core/Picker.svelte +++ b/packages/bbui/src/Form/Core/Picker.svelte @@ -33,6 +33,9 @@ export let sort = false export let fetchTerm = null export let customPopoverHeight + export let align = "left" + export let footer = null + const dispatch = createEventDispatcher() let searchTerm = null @@ -131,7 +134,7 @@ (open = false)} @@ -208,6 +211,12 @@ {/each} {/if} + + {#if footer} + + {/if} @@ -284,4 +293,11 @@ .popover-content :global(.spectrum-Search .spectrum-Textfield-icon) { top: 9px; } + + .footer { + padding: 4px 12px 12px 12px; + font-style: italic; + max-width: 170px; + font-size: 12px; + } diff --git a/packages/bbui/src/Form/Core/Select.svelte b/packages/bbui/src/Form/Core/Select.svelte index 721083e3a6..a329933670 100644 --- a/packages/bbui/src/Form/Core/Select.svelte +++ b/packages/bbui/src/Form/Core/Select.svelte @@ -18,6 +18,8 @@ export let autoWidth = false export let autocomplete = false export let sort = false + export let align + export let footer = null const dispatch = createEventDispatcher() @@ -41,7 +43,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) @@ -66,6 +68,8 @@ {fieldColour} {options} {autoWidth} + {align} + {footer} {getOptionLabel} {getOptionValue} {getOptionIcon} @@ -74,7 +78,7 @@ {autocomplete} {sort} isPlaceholder={value == null || value === ""} - placeholderOption={placeholder} + placeholderOption={placeholder === false ? null : placeholder} isOptionSelected={option => option === value} onSelectOption={selectOption} /> diff --git a/packages/bbui/src/Form/Select.svelte b/packages/bbui/src/Form/Select.svelte index 76fe613c92..17074968fa 100644 --- a/packages/bbui/src/Form/Select.svelte +++ b/packages/bbui/src/Form/Select.svelte @@ -22,6 +22,8 @@ export let tooltip = "" export let autocomplete = false export let customPopoverHeight + export let align + export let footer = null const dispatch = createEventDispatcher() const onChange = e => { @@ -48,6 +50,8 @@ {placeholder} {autoWidth} {sort} + {align} + {footer} {getOptionLabel} {getOptionValue} {getOptionIcon} diff --git a/packages/builder/src/builderStore/store/frontend.js b/packages/builder/src/builderStore/store/frontend.js index d58a2d5b9e..51f88add27 100644 --- a/packages/builder/src/builderStore/store/frontend.js +++ b/packages/builder/src/builderStore/store/frontend.js @@ -72,6 +72,8 @@ const INITIAL_FRONTEND_STATE = { // onboarding onboarding: false, tourNodes: null, + + builderSidePanel: false, } export const getFrontendStore = () => { diff --git a/packages/builder/src/components/common/RoleSelect.svelte b/packages/builder/src/components/common/RoleSelect.svelte index 645e82c8ba..09a67cb6fe 100644 --- a/packages/builder/src/components/common/RoleSelect.svelte +++ b/packages/builder/src/components/common/RoleSelect.svelte @@ -11,16 +11,24 @@ export let quiet = false export let allowPublic = true export let allowRemove = false + export let disabled = false + export let align + export let footer = null + export let allowedRoles = null const dispatch = createEventDispatcher() const RemoveID = "remove" - $: options = getOptions($roles, allowPublic, allowRemove) + $: options = getOptions($roles, allowPublic, allowRemove, allowedRoles) - const getOptions = (roles, allowPublic) => { + const getOptions = (roles, allowPublic, allowRemove, allowedRoles) => { + if (allowedRoles?.length) { + return roles.filter(role => allowedRoles.includes(role._id)) + } + let newRoles = [...roles] if (allowRemove) { - roles = [ - ...roles, + newRoles = [ + ...newRoles, { _id: RemoveID, name: "Remove", @@ -28,9 +36,9 @@ ] } if (allowPublic) { - return roles + return newRoles } - return roles.filter(role => role._id !== Constants.Roles.PUBLIC) + return newRoles.filter(role => role._id !== Constants.Roles.PUBLIC) } const getColor = role => { @@ -59,6 +67,9 @@ { + query = e.target.value.trim() + }} + on:focus={() => (searchFocus = true)} + on:blur={() => (searchFocus = false)} + /> + + + { + if (!query) { + return + } + query = null + userOnboardResponse = null + }} + > + + + + +
+ {#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) + }} + autoWidth + align="right" + /> +
+
+ {/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) + }} + autoWidth + align="right" + /> +
+
+ {/each} +
+ {/if} + + {#if filteredUsers?.length} +
+
+
Users
+
Access
+
+ {#each allUsers as user} +
+
+
+ {user.email} +
+
+ {userTitle(user)} +
+
+
+ { + onUpdateUser(user, e.detail) + }} + on:remove={() => { + onUpdateUser(user) + }} + autoWidth + align="right" + allowedRoles={user.isBuilderOrAdmin + ? [Constants.Roles.ADMIN] + : null} + /> +
+
+ {/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..a4b4982fca 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 @@ -69,22 +68,32 @@ } const initTour = async () => { - if ( - !$auth.user?.onboardedAt && - isEnabled(TENANT_FEATURE_FLAGS.ONBOARDING_TOUR) - ) { - // Determine the correct step - const activeNav = $layout.children.find(c => $isActive(c.path)) - const onboardingTour = TOURS[TOUR_KEYS.TOUR_BUILDER_ONBOARDING] - const targetStep = activeNav - ? onboardingTour.find(step => step.route === activeNav?.path) - : null - await store.update(state => ({ - ...state, - onboarding: true, - tourKey: TOUR_KEYS.TOUR_BUILDER_ONBOARDING, - tourStepKey: targetStep?.id, - })) + // Check if onboarding is enabled. + if (isEnabled(TENANT_FEATURE_FLAGS.ONBOARDING_TOUR)) { + if (!$auth.user?.onboardedAt) { + // Determine the correct step + const activeNav = $layout.children.find(c => $isActive(c.path)) + const onboardingTour = TOURS[TOUR_KEYS.TOUR_BUILDER_ONBOARDING] + const targetStep = activeNav + ? onboardingTour.find(step => step.route === activeNav?.path) + : null + await store.update(state => ({ + ...state, + onboarding: true, + tourKey: TOUR_KEYS.TOUR_BUILDER_ONBOARDING, + tourStepKey: targetStep?.id, + })) + } else { + // Feature tour date + const release_date = new Date("2023-03-01T00:00:00.000Z") + const onboarded = new Date($auth.user?.onboardedAt) + if (onboarded < release_date) { + await store.update(state => ({ + ...state, + tourKey: TOUR_KEYS.FEATURE_ONBOARDING, + })) + } + } } } @@ -116,6 +125,11 @@
{:then _} + + {#if $store.builderSidePanel} + + {/if} +
@@ -181,11 +195,7 @@
-
- -
- - +
@@ -250,10 +260,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 a435808f7e..63d6be6fa0 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[] } @@ -49,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 c722d27faa..d68e726e71 100644 --- a/packages/worker/src/api/controllers/global/users.ts +++ b/packages/worker/src/api/controllers/global/users.ts @@ -1,4 +1,9 @@ -import { checkInviteCode } from "../../../utilities/redis" +import { + checkInviteCode, + getInviteCodes, + updateInviteCode, +} from "../../../utilities/redis" +// import sdk from "../../../sdk" import * as userSdk from "../../../sdk/users" import env from "../../../environment" import { @@ -28,6 +33,7 @@ import { platform, } from "@budibase/backend-core" import { checkAnyUserExists } from "../../../utilities/users" +import { isEmailConfigured } from "../../../utilities/email" const MAX_USERS_UPLOAD_LIMIT = 1000 @@ -179,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 @@ -218,9 +236,71 @@ 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 userSdk.bulkCreate(assignUsers, groups) + ctx.body = onboardingResponse + } else if (emailConfigured) { + onboardingResponse = await inviteMultiple(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, + forceResetPassword: true, + roles: invite.userInfo.apps, + admin: { global: false }, + builder: { global: false }, + tenantId: tenancy.getTenantId(), + } + }) + let bulkCreateReponse = await userSdk.bulkCreate(users, []) + + // Apply temporary credentials + let createWithCredentials = { + ...bulkCreateReponse, + successful: bulkCreateReponse?.successful.map(user => { + return { + ...user, + password: createdPasswords[user.email], + } + }), + created: true, + } + + 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]) + + let multiRequest = [request] as InviteUsersRequest + const response = await userSdk.invite(multiRequest) // explicitly throw for single user invite if (response.unsuccessful.length) { @@ -234,6 +314,8 @@ export const invite = async (ctx: any) => { ctx.body = { message: "Invitation has been sent.", + successful: response.successful, + unsuccessful: response.unsuccessful, } } @@ -255,6 +337,53 @@ 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: Ctx ) => { @@ -263,13 +392,23 @@ export const inviteAccept = async ( // info is an extension of the user object that was stored by global const { email, info }: any = await checkInviteCode(inviteCode) const user = await tenancy.doInTenant(info.tenantId, async () => { - const saved = await userSdk.save({ + let request = { firstName, lastName, password, email, + roles: info.apps, + tenantId: info.tenantId, + } + + delete info.apps + + request = { + ...request, ...info, - }) + } + + const saved = await userSdk.save(request) const db = tenancy.getGlobalDB() const user = await db.get(saved._id) await events.user.inviteAccepted(user) diff --git a/packages/worker/src/api/routes/global/tests/users.spec.ts b/packages/worker/src/api/routes/global/tests/users.spec.ts index 31ef1d9b0c..085c976649 100644 --- a/packages/worker/src/api/routes/global/tests/users.spec.ts +++ b/packages/worker/src/api/routes/global/tests/users.spec.ts @@ -30,7 +30,11 @@ describe("/api/global/users", () => { email ) - expect(res.body).toEqual({ message: "Invitation has been sent." }) + expect(res.body?.message).toBe("Invitation has been sent.") + expect(res.body?.unsuccessful.length).toBe(0) + expect(res.body?.successful.length).toBe(1) + expect(res.body?.successful[0].email).toBe(email) + expect(sendMailMock).toHaveBeenCalled() expect(code).toBeDefined() expect(events.user.invited).toBeCalledTimes(1) 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 18d5a04cda..9de90eae03 100644 --- a/packages/worker/src/sdk/users/users.ts +++ b/packages/worker/src/sdk/users/users.ts @@ -57,11 +57,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 @@ -234,7 +245,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") @@ -276,6 +287,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 9171fe97ee..ecfc027cad 100644 --- a/packages/worker/src/utilities/redis.ts +++ b/packages/worker/src/utilities/redis.ts @@ -7,7 +7,7 @@ function getExpirySecondsForDB(db: string) { return 3600 case redis.utils.Databases.INVITATIONS: // a day - return 86400 + return 604800 } } @@ -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) @@ -113,3 +127,27 @@ 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 list of all objects containing invite metadata +**/ +export async function getInviteCodes(tenantIds?: string[]) { + 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 + }, []) +}