@@ -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
+ }, [])
+}