Merge branch 'master' into fix/build-references
This commit is contained in:
commit
00f65f396e
|
@ -51,6 +51,7 @@ export * from "./constants"
|
||||||
|
|
||||||
// expose package init function
|
// expose package init function
|
||||||
import * as db from "./db"
|
import * as db from "./db"
|
||||||
|
|
||||||
export const init = (opts: any = {}) => {
|
export const init = (opts: any = {}) => {
|
||||||
db.init(opts.db)
|
db.init(opts.db)
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,3 +4,5 @@ export { default as Client } from "./redis"
|
||||||
export * as utils from "./utils"
|
export * as utils from "./utils"
|
||||||
export * as clients from "./init"
|
export * as clients from "./init"
|
||||||
export * as locks from "./redlockImpl"
|
export * as locks from "./redlockImpl"
|
||||||
|
export * as invite from "./invite"
|
||||||
|
export * as passwordReset from "./passwordReset"
|
||||||
|
|
|
@ -0,0 +1,91 @@
|
||||||
|
import { utils, tenancy, redis } from "../"
|
||||||
|
import env from "../environment"
|
||||||
|
|
||||||
|
const TTL_SECONDS = 60 * 60 * 24 * 7
|
||||||
|
|
||||||
|
interface Invite {
|
||||||
|
email: string
|
||||||
|
info: any
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InviteWithCode extends Invite {
|
||||||
|
code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let client: redis.Client
|
||||||
|
|
||||||
|
export async function init() {
|
||||||
|
if (!client) {
|
||||||
|
client = new redis.Client(redis.utils.Databases.INVITATIONS)
|
||||||
|
}
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function shutdown() {
|
||||||
|
if (client) await client.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given an invite code and invite body, allow the update an existing/valid invite in redis
|
||||||
|
* @param inviteCode The invite code for an invite in redis
|
||||||
|
* @param value The body of the updated user invitation
|
||||||
|
*/
|
||||||
|
export async function updateCode(code: string, value: Invite) {
|
||||||
|
await client.store(code, value, TTL_SECONDS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an invitation code and writes it to redis - which can later be checked for user creation.
|
||||||
|
* @param email the email address which the code is being sent to (for use later).
|
||||||
|
* @param info Information to be carried along with the invitation.
|
||||||
|
* @return returns the code that was stored to redis.
|
||||||
|
*/
|
||||||
|
export async function createCode(email: string, info: any): Promise<string> {
|
||||||
|
const code = utils.newid()
|
||||||
|
await client.store(code, { email, info }, TTL_SECONDS)
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks that the provided invite code is valid - will return the email address of user that was invited.
|
||||||
|
* @param inviteCode the invite code that was provided as part of the link.
|
||||||
|
* @return If the code is valid then an email address will be returned.
|
||||||
|
*/
|
||||||
|
export async function getCode(code: string): Promise<Invite> {
|
||||||
|
const value = (await client.get(code)) as Invite | undefined
|
||||||
|
if (!value) {
|
||||||
|
throw "Invitation is not valid or has expired, please request a new one."
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCode(code: string) {
|
||||||
|
await client.delete(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Get all currently available user invitations for the current tenant.
|
||||||
|
**/
|
||||||
|
export async function getInviteCodes(): Promise<InviteWithCode[]> {
|
||||||
|
const invites: { key: string; value: Invite }[] = await client.scan()
|
||||||
|
|
||||||
|
const results: InviteWithCode[] = invites.map(invite => {
|
||||||
|
return {
|
||||||
|
...invite.value,
|
||||||
|
code: invite.key,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!env.MULTI_TENANCY) {
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
const tenantId = tenancy.getTenantId()
|
||||||
|
return results.filter(invite => tenantId === invite.info.tenantId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getExistingInvites(
|
||||||
|
emails: string[]
|
||||||
|
): Promise<InviteWithCode[]> {
|
||||||
|
return (await getInviteCodes()).filter(invite =>
|
||||||
|
emails.includes(invite.email)
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { redis, utils } from "../"
|
||||||
|
|
||||||
|
const TTL_SECONDS = 60 * 60
|
||||||
|
|
||||||
|
interface PasswordReset {
|
||||||
|
userId: string
|
||||||
|
info: any
|
||||||
|
}
|
||||||
|
|
||||||
|
let client: redis.Client
|
||||||
|
|
||||||
|
export async function init() {
|
||||||
|
if (!client) {
|
||||||
|
client = new redis.Client(redis.utils.Databases.PW_RESETS)
|
||||||
|
}
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function shutdown() {
|
||||||
|
if (client) await client.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a user ID this will store a code (that is returned) for an hour in redis.
|
||||||
|
* The user can then return this code for resetting their password (through their reset link).
|
||||||
|
* @param userId the ID of the user which is to be reset.
|
||||||
|
* @param info Info about the user/the reset process.
|
||||||
|
* @return returns the code that was stored to redis.
|
||||||
|
*/
|
||||||
|
export async function createCode(userId: string, info: any): Promise<string> {
|
||||||
|
const code = utils.newid()
|
||||||
|
await client.store(code, { userId, info }, TTL_SECONDS)
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a reset code this will lookup to redis, check if the code is valid.
|
||||||
|
* @param code The code provided via the email link.
|
||||||
|
* @return returns the user ID if it is found
|
||||||
|
*/
|
||||||
|
export async function getCode(code: string): Promise<PasswordReset> {
|
||||||
|
const value = (await client.get(code)) as PasswordReset | undefined
|
||||||
|
if (!value) {
|
||||||
|
throw "Provided information is not valid, cannot reset password - please try again."
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
|
@ -303,7 +303,7 @@ export class UserDB {
|
||||||
|
|
||||||
static async bulkCreate(
|
static async bulkCreate(
|
||||||
newUsersRequested: User[],
|
newUsersRequested: User[],
|
||||||
groups: string[]
|
groups?: string[]
|
||||||
): Promise<BulkUserCreated> {
|
): Promise<BulkUserCreated> {
|
||||||
const tenantId = getTenantId()
|
const tenantId = getTenantId()
|
||||||
|
|
||||||
|
@ -328,7 +328,7 @@ export class UserDB {
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
newUser.userGroups = groups
|
newUser.userGroups = groups || []
|
||||||
newUsers.push(newUser)
|
newUsers.push(newUser)
|
||||||
if (isCreator(newUser)) {
|
if (isCreator(newUser)) {
|
||||||
newCreators.push(newUser)
|
newCreators.push(newUser)
|
||||||
|
|
|
@ -6,6 +6,7 @@ import {
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import * as dbUtils from "../db"
|
import * as dbUtils from "../db"
|
||||||
import { ViewName } from "../constants"
|
import { ViewName } from "../constants"
|
||||||
|
import { getExistingInvites } from "../redis/invite"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply a system-wide search on emails:
|
* Apply a system-wide search on emails:
|
||||||
|
@ -26,6 +27,9 @@ export async function searchExistingEmails(emails: string[]) {
|
||||||
const existingAccounts = await getExistingAccounts(emails)
|
const existingAccounts = await getExistingAccounts(emails)
|
||||||
matchedEmails.push(...existingAccounts.map(account => account.email))
|
matchedEmails.push(...existingAccounts.map(account => account.email))
|
||||||
|
|
||||||
|
const invitedEmails = await getExistingInvites(emails)
|
||||||
|
matchedEmails.push(...invitedEmails.map(invite => invite.email))
|
||||||
|
|
||||||
return [...new Set(matchedEmails.map(email => email.toLowerCase()))]
|
return [...new Set(matchedEmails.map(email => email.toLowerCase()))]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -12,7 +12,7 @@ import { generator } from "./generator"
|
||||||
import { tenant } from "."
|
import { tenant } from "."
|
||||||
|
|
||||||
export const newEmail = () => {
|
export const newEmail = () => {
|
||||||
return `${uuid()}@test.com`
|
return `${uuid()}@example.com`
|
||||||
}
|
}
|
||||||
|
|
||||||
export const user = (userProps?: Partial<Omit<User, "userId">>): User => {
|
export const user = (userProps?: Partial<Omit<User, "userId">>): User => {
|
||||||
|
|
|
@ -10,6 +10,7 @@ export interface SaveUserResponse {
|
||||||
export interface UserDetails {
|
export interface UserDetails {
|
||||||
_id: string
|
_id: string
|
||||||
email: string
|
email: string
|
||||||
|
password?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BulkUserRequest {
|
export interface BulkUserRequest {
|
||||||
|
@ -49,6 +50,7 @@ export type InviteUsersRequest = InviteUserRequest[]
|
||||||
export interface InviteUsersResponse {
|
export interface InviteUsersResponse {
|
||||||
successful: { email: string }[]
|
successful: { email: string }[]
|
||||||
unsuccessful: { email: string; reason: string }[]
|
unsuccessful: { email: string; reason: string }[]
|
||||||
|
created?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchUsersRequest {
|
export interface SearchUsersRequest {
|
||||||
|
|
|
@ -1,8 +1,4 @@
|
||||||
import {
|
import { redis } from "@budibase/backend-core"
|
||||||
checkInviteCode,
|
|
||||||
getInviteCodes,
|
|
||||||
updateInviteCode,
|
|
||||||
} from "../../../utilities/redis"
|
|
||||||
import * as userSdk from "../../../sdk/users"
|
import * as userSdk from "../../../sdk/users"
|
||||||
import env from "../../../environment"
|
import env from "../../../environment"
|
||||||
import {
|
import {
|
||||||
|
@ -16,6 +12,7 @@ import {
|
||||||
Ctx,
|
Ctx,
|
||||||
InviteUserRequest,
|
InviteUserRequest,
|
||||||
InviteUsersRequest,
|
InviteUsersRequest,
|
||||||
|
InviteUsersResponse,
|
||||||
MigrationType,
|
MigrationType,
|
||||||
SaveUserResponse,
|
SaveUserResponse,
|
||||||
SearchUsersRequest,
|
SearchUsersRequest,
|
||||||
|
@ -249,59 +246,35 @@ export const tenantUserLookup = async (ctx: any) => {
|
||||||
/*
|
/*
|
||||||
Encapsulate the app user onboarding flows here.
|
Encapsulate the app user onboarding flows here.
|
||||||
*/
|
*/
|
||||||
export const onboardUsers = async (ctx: Ctx<InviteUsersRequest>) => {
|
export const onboardUsers = async (
|
||||||
const request = ctx.request.body
|
ctx: Ctx<InviteUsersRequest, InviteUsersResponse>
|
||||||
const isBulkCreate = "create" in request
|
) => {
|
||||||
|
if (await isEmailConfigured()) {
|
||||||
const emailConfigured = await isEmailConfigured()
|
await inviteMultiple(ctx)
|
||||||
|
return
|
||||||
let onboardingResponse
|
|
||||||
|
|
||||||
if (isBulkCreate) {
|
|
||||||
// @ts-ignore
|
|
||||||
const { users, groups, roles } = request.create
|
|
||||||
const assignUsers = users.map((user: User) => (user.roles = roles))
|
|
||||||
onboardingResponse = await userSdk.db.bulkCreate(assignUsers, groups)
|
|
||||||
ctx.body = onboardingResponse
|
|
||||||
} else if (emailConfigured) {
|
|
||||||
onboardingResponse = await inviteMultiple(ctx)
|
|
||||||
} else if (!emailConfigured) {
|
|
||||||
const inviteRequest = ctx.request.body
|
|
||||||
|
|
||||||
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: invite.userInfo.admin,
|
|
||||||
builder: invite.userInfo.builder,
|
|
||||||
tenantId: tenancy.getTenantId(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
let bulkCreateReponse = await userSdk.db.bulkCreate(users, [])
|
|
||||||
|
|
||||||
// Apply temporary credentials
|
|
||||||
ctx.body = {
|
|
||||||
...bulkCreateReponse,
|
|
||||||
successful: bulkCreateReponse?.successful.map(user => {
|
|
||||||
return {
|
|
||||||
...user,
|
|
||||||
password: createdPasswords[user.email],
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
created: true,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ctx.throw(400, "User onboarding failed")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let createdPasswords: Record<string, string> = {}
|
||||||
|
const users: User[] = ctx.request.body.map(invite => {
|
||||||
|
let password = Math.random().toString(36).substring(2, 22)
|
||||||
|
createdPasswords[invite.email] = password
|
||||||
|
|
||||||
|
return {
|
||||||
|
email: invite.email,
|
||||||
|
password,
|
||||||
|
forceResetPassword: true,
|
||||||
|
roles: invite.userInfo.apps,
|
||||||
|
admin: invite.userInfo.admin,
|
||||||
|
builder: invite.userInfo.builder,
|
||||||
|
tenantId: tenancy.getTenantId(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let resp = await userSdk.db.bulkCreate(users)
|
||||||
|
for (const user of resp.successful) {
|
||||||
|
user.password = createdPasswords[user.email]
|
||||||
|
}
|
||||||
|
ctx.body = { ...resp, created: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const invite = async (ctx: Ctx<InviteUserRequest>) => {
|
export const invite = async (ctx: Ctx<InviteUserRequest>) => {
|
||||||
|
@ -328,18 +301,18 @@ export const invite = async (ctx: Ctx<InviteUserRequest>) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const inviteMultiple = async (ctx: Ctx<InviteUsersRequest>) => {
|
export const inviteMultiple = async (ctx: Ctx<InviteUsersRequest>) => {
|
||||||
const request = ctx.request.body
|
ctx.body = await userSdk.invite(ctx.request.body)
|
||||||
ctx.body = await userSdk.invite(request)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const checkInvite = async (ctx: any) => {
|
export const checkInvite = async (ctx: any) => {
|
||||||
const { code } = ctx.params
|
const { code } = ctx.params
|
||||||
let invite
|
let invite
|
||||||
try {
|
try {
|
||||||
invite = await checkInviteCode(code, false)
|
invite = await redis.invite.getCode(code)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Error getting invite from code", e)
|
console.warn("Error getting invite from code", e)
|
||||||
ctx.throw(400, "There was a problem with the invite")
|
ctx.throw(400, "There was a problem with the invite")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
email: invite.email,
|
email: invite.email,
|
||||||
|
@ -347,14 +320,12 @@ export const checkInvite = async (ctx: any) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getUserInvites = async (ctx: any) => {
|
export const getUserInvites = async (ctx: any) => {
|
||||||
let invites
|
|
||||||
try {
|
try {
|
||||||
// Restricted to the currently authenticated tenant
|
// Restricted to the currently authenticated tenant
|
||||||
invites = await getInviteCodes()
|
ctx.body = await redis.invite.getInviteCodes()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ctx.throw(400, "There was a problem fetching invites")
|
ctx.throw(400, "There was a problem fetching invites")
|
||||||
}
|
}
|
||||||
ctx.body = invites
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateInvite = async (ctx: any) => {
|
export const updateInvite = async (ctx: any) => {
|
||||||
|
@ -365,12 +336,10 @@ export const updateInvite = async (ctx: any) => {
|
||||||
|
|
||||||
let invite
|
let invite
|
||||||
try {
|
try {
|
||||||
invite = await checkInviteCode(code, false)
|
invite = await redis.invite.getCode(code)
|
||||||
if (!invite) {
|
|
||||||
throw new Error("The invite could not be retrieved")
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ctx.throw(400, "There was a problem with the invite")
|
ctx.throw(400, "There was a problem with the invite")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let updated = {
|
let updated = {
|
||||||
|
@ -395,7 +364,7 @@ export const updateInvite = async (ctx: any) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateInviteCode(code, updated)
|
await redis.invite.updateCode(code, updated)
|
||||||
ctx.body = { ...invite }
|
ctx.body = { ...invite }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -405,7 +374,8 @@ export const inviteAccept = async (
|
||||||
const { inviteCode, password, firstName, lastName } = ctx.request.body
|
const { inviteCode, password, firstName, lastName } = ctx.request.body
|
||||||
try {
|
try {
|
||||||
// info is an extension of the user object that was stored by global
|
// info is an extension of the user object that was stored by global
|
||||||
const { email, info }: any = await checkInviteCode(inviteCode)
|
const { email, info }: any = await redis.invite.getCode(inviteCode)
|
||||||
|
await redis.invite.deleteCode(inviteCode)
|
||||||
const user = await tenancy.doInTenant(info.tenantId, async () => {
|
const user = await tenancy.doInTenant(info.tenantId, async () => {
|
||||||
let request: any = {
|
let request: any = {
|
||||||
firstName,
|
firstName,
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
import { InviteUsersResponse, User } from "@budibase/types"
|
import { InviteUsersResponse, User } from "@budibase/types"
|
||||||
|
|
||||||
jest.mock("nodemailer")
|
|
||||||
import { TestConfiguration, mocks, structures } from "../../../../tests"
|
import { TestConfiguration, mocks, structures } from "../../../../tests"
|
||||||
const sendMailMock = mocks.email.mock()
|
|
||||||
import { events, tenancy, accounts as _accounts } from "@budibase/backend-core"
|
import { events, tenancy, accounts as _accounts } from "@budibase/backend-core"
|
||||||
import * as userSdk from "../../../../sdk/users"
|
import * as userSdk from "../../../../sdk/users"
|
||||||
|
|
||||||
|
jest.mock("nodemailer")
|
||||||
|
const sendMailMock = mocks.email.mock()
|
||||||
|
|
||||||
const accounts = jest.mocked(_accounts)
|
const accounts = jest.mocked(_accounts)
|
||||||
|
|
||||||
describe("/api/global/users", () => {
|
describe("/api/global/users", () => {
|
||||||
|
@ -54,6 +55,24 @@ describe("/api/global/users", () => {
|
||||||
expect(events.user.invited).toBeCalledTimes(0)
|
expect(events.user.invited).toBeCalledTimes(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("should not invite the same user twice", async () => {
|
||||||
|
const email = structures.users.newEmail()
|
||||||
|
await config.api.users.sendUserInvite(sendMailMock, email)
|
||||||
|
|
||||||
|
jest.clearAllMocks()
|
||||||
|
|
||||||
|
const { code, res } = await config.api.users.sendUserInvite(
|
||||||
|
sendMailMock,
|
||||||
|
email,
|
||||||
|
400
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(res.body.message).toBe(`Unavailable`)
|
||||||
|
expect(sendMailMock).toHaveBeenCalledTimes(0)
|
||||||
|
expect(code).toBeUndefined()
|
||||||
|
expect(events.user.invited).toBeCalledTimes(0)
|
||||||
|
})
|
||||||
|
|
||||||
it("should be able to create new user from invite", async () => {
|
it("should be able to create new user from invite", async () => {
|
||||||
const email = structures.users.newEmail()
|
const email = structures.users.newEmail()
|
||||||
const { code } = await config.api.users.sendUserInvite(
|
const { code } = await config.api.users.sendUserInvite(
|
||||||
|
@ -101,6 +120,23 @@ describe("/api/global/users", () => {
|
||||||
expect(sendMailMock).toHaveBeenCalledTimes(0)
|
expect(sendMailMock).toHaveBeenCalledTimes(0)
|
||||||
expect(events.user.invited).toBeCalledTimes(0)
|
expect(events.user.invited).toBeCalledTimes(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("should not be able to generate an invitation for user that has already been invited", async () => {
|
||||||
|
const email = structures.users.newEmail()
|
||||||
|
await config.api.users.sendUserInvite(sendMailMock, email)
|
||||||
|
|
||||||
|
jest.clearAllMocks()
|
||||||
|
|
||||||
|
const request = [{ email: email, userInfo: {} }]
|
||||||
|
const res = await config.api.users.sendMultiUserInvite(request)
|
||||||
|
|
||||||
|
const body = res.body as InviteUsersResponse
|
||||||
|
expect(body.successful.length).toBe(0)
|
||||||
|
expect(body.unsuccessful.length).toBe(1)
|
||||||
|
expect(body.unsuccessful[0].reason).toBe("Unavailable")
|
||||||
|
expect(sendMailMock).toHaveBeenCalledTimes(0)
|
||||||
|
expect(events.user.invited).toBeCalledTimes(0)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("POST /api/global/users/bulk", () => {
|
describe("POST /api/global/users/bulk", () => {
|
||||||
|
@ -633,4 +669,25 @@ describe("/api/global/users", () => {
|
||||||
expect(response.body.message).toBe("Unable to delete self.")
|
expect(response.body.message).toBe("Unable to delete self.")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("POST /api/global/users/onboard", () => {
|
||||||
|
it("should successfully onboard a user", async () => {
|
||||||
|
const response = await config.api.users.onboardUser([
|
||||||
|
{ email: structures.users.newEmail(), userInfo: {} },
|
||||||
|
])
|
||||||
|
expect(response.successful.length).toBe(1)
|
||||||
|
expect(response.unsuccessful.length).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not onboard a user who has been invited", async () => {
|
||||||
|
const email = structures.users.newEmail()
|
||||||
|
await config.api.users.sendUserInvite(sendMailMock, email)
|
||||||
|
|
||||||
|
const response = await config.api.users.onboardUser([
|
||||||
|
{ email, userInfo: {} },
|
||||||
|
])
|
||||||
|
expect(response.successful.length).toBe(0)
|
||||||
|
expect(response.unsuccessful.length).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -16,13 +16,13 @@ import {
|
||||||
queue,
|
queue,
|
||||||
env as coreEnv,
|
env as coreEnv,
|
||||||
timers,
|
timers,
|
||||||
|
redis,
|
||||||
} from "@budibase/backend-core"
|
} from "@budibase/backend-core"
|
||||||
db.init()
|
db.init()
|
||||||
import Koa from "koa"
|
import Koa from "koa"
|
||||||
import koaBody from "koa-body"
|
import koaBody from "koa-body"
|
||||||
import http from "http"
|
import http from "http"
|
||||||
import api from "./api"
|
import api from "./api"
|
||||||
import * as redis from "./utilities/redis"
|
|
||||||
|
|
||||||
const koaSession = require("koa-session")
|
const koaSession = require("koa-session")
|
||||||
import { userAgent } from "koa-useragent"
|
import { userAgent } from "koa-useragent"
|
||||||
|
@ -72,7 +72,8 @@ server.on("close", async () => {
|
||||||
shuttingDown = true
|
shuttingDown = true
|
||||||
console.log("Server Closed")
|
console.log("Server Closed")
|
||||||
timers.cleanup()
|
timers.cleanup()
|
||||||
await redis.shutdown()
|
await redis.invite.shutdown()
|
||||||
|
await redis.passwordReset.shutdown()
|
||||||
await events.shutdown()
|
await events.shutdown()
|
||||||
await queue.shutdown()
|
await queue.shutdown()
|
||||||
if (!env.isTest()) {
|
if (!env.isTest()) {
|
||||||
|
@ -88,7 +89,8 @@ const shutdown = () => {
|
||||||
export default server.listen(parseInt(env.PORT || "4002"), async () => {
|
export default server.listen(parseInt(env.PORT || "4002"), async () => {
|
||||||
console.log(`Worker running on ${JSON.stringify(server.address())}`)
|
console.log(`Worker running on ${JSON.stringify(server.address())}`)
|
||||||
await initPro()
|
await initPro()
|
||||||
await redis.init()
|
await redis.invite.init()
|
||||||
|
await redis.passwordReset.init()
|
||||||
// configure events to use the pro audit log write
|
// configure events to use the pro audit log write
|
||||||
// can't integrate directly into backend-core due to cyclic issues
|
// can't integrate directly into backend-core due to cyclic issues
|
||||||
await events.processors.init(proSdk.auditLogs.write)
|
await events.processors.init(proSdk.auditLogs.write)
|
||||||
|
|
|
@ -6,12 +6,12 @@ import {
|
||||||
sessions,
|
sessions,
|
||||||
tenancy,
|
tenancy,
|
||||||
utils as coreUtils,
|
utils as coreUtils,
|
||||||
|
redis,
|
||||||
} from "@budibase/backend-core"
|
} from "@budibase/backend-core"
|
||||||
import { PlatformLogoutOpts, User } from "@budibase/types"
|
import { PlatformLogoutOpts, User } from "@budibase/types"
|
||||||
import jwt from "jsonwebtoken"
|
import jwt from "jsonwebtoken"
|
||||||
import * as userSdk from "../users"
|
import * as userSdk from "../users"
|
||||||
import * as emails from "../../utilities/email"
|
import * as emails from "../../utilities/email"
|
||||||
import * as redis from "../../utilities/redis"
|
|
||||||
import { EmailTemplatePurpose } from "../../constants"
|
import { EmailTemplatePurpose } from "../../constants"
|
||||||
|
|
||||||
// LOGIN / LOGOUT
|
// LOGIN / LOGOUT
|
||||||
|
@ -73,7 +73,7 @@ export const reset = async (email: string) => {
|
||||||
* Perform the user password update if the provided reset code is valid.
|
* Perform the user password update if the provided reset code is valid.
|
||||||
*/
|
*/
|
||||||
export const resetUpdate = async (resetCode: string, password: string) => {
|
export const resetUpdate = async (resetCode: string, password: string) => {
|
||||||
const { userId } = await redis.checkResetPasswordCode(resetCode)
|
const { userId } = await redis.passwordReset.getCode(resetCode)
|
||||||
|
|
||||||
let user = await userSdk.db.getUser(userId)
|
let user = await userSdk.db.getUser(userId)
|
||||||
user.password = password
|
user.password = password
|
||||||
|
|
|
@ -1,5 +1,9 @@
|
||||||
import { events, tenancy, users as usersCore } from "@budibase/backend-core"
|
import { events, tenancy, users as usersCore } from "@budibase/backend-core"
|
||||||
import { InviteUsersRequest, InviteUsersResponse } from "@budibase/types"
|
import {
|
||||||
|
InviteUserRequest,
|
||||||
|
InviteUsersRequest,
|
||||||
|
InviteUsersResponse,
|
||||||
|
} from "@budibase/types"
|
||||||
import { sendEmail } from "../../utilities/email"
|
import { sendEmail } from "../../utilities/email"
|
||||||
import { EmailTemplatePurpose } from "../../constants"
|
import { EmailTemplatePurpose } from "../../constants"
|
||||||
|
|
||||||
|
@ -14,11 +18,13 @@ export async function invite(
|
||||||
const matchedEmails = await usersCore.searchExistingEmails(
|
const matchedEmails = await usersCore.searchExistingEmails(
|
||||||
users.map(u => u.email)
|
users.map(u => u.email)
|
||||||
)
|
)
|
||||||
const newUsers = []
|
const newUsers: InviteUserRequest[] = []
|
||||||
|
|
||||||
// separate duplicates from new users
|
// separate duplicates from new users
|
||||||
for (let user of users) {
|
for (let user of users) {
|
||||||
if (matchedEmails.includes(user.email)) {
|
if (matchedEmails.includes(user.email)) {
|
||||||
|
// This "Unavailable" is load bearing. The tests and frontend both check for it
|
||||||
|
// specifically
|
||||||
response.unsuccessful.push({ email: user.email, reason: "Unavailable" })
|
response.unsuccessful.push({ email: user.email, reason: "Unavailable" })
|
||||||
} else {
|
} else {
|
||||||
newUsers.push(user)
|
newUsers.push(user)
|
||||||
|
|
|
@ -5,6 +5,7 @@ import {
|
||||||
User,
|
User,
|
||||||
CreateAdminUserRequest,
|
CreateAdminUserRequest,
|
||||||
SearchQuery,
|
SearchQuery,
|
||||||
|
InviteUsersResponse,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import structures from "../structures"
|
import structures from "../structures"
|
||||||
import { generator } from "@budibase/backend-core/tests"
|
import { generator } from "@budibase/backend-core/tests"
|
||||||
|
@ -176,4 +177,24 @@ export class UserAPI extends TestAPI {
|
||||||
.expect("Content-Type", /json/)
|
.expect("Content-Type", /json/)
|
||||||
.expect(200)
|
.expect(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onboardUser = async (
|
||||||
|
req: InviteUsersRequest
|
||||||
|
): Promise<InviteUsersResponse> => {
|
||||||
|
const resp = await this.request
|
||||||
|
.post(`/api/global/users/onboard`)
|
||||||
|
.send(req)
|
||||||
|
.set(this.config.defaultHeaders())
|
||||||
|
.expect("Content-Type", /json/)
|
||||||
|
|
||||||
|
if (resp.status !== 200) {
|
||||||
|
throw new Error(
|
||||||
|
`request failed with status ${resp.status} and body ${JSON.stringify(
|
||||||
|
resp.body
|
||||||
|
)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.body as InviteUsersResponse
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { EmailTemplatePurpose, TemplateType } from "../constants"
|
||||||
import { getTemplateByPurpose, EmailTemplates } from "../constants/templates"
|
import { getTemplateByPurpose, EmailTemplates } from "../constants/templates"
|
||||||
import { getSettingsTemplateContext } from "./templates"
|
import { getSettingsTemplateContext } from "./templates"
|
||||||
import { processString } from "@budibase/string-templates"
|
import { processString } from "@budibase/string-templates"
|
||||||
import { getResetPasswordCode, getInviteCode } from "./redis"
|
import { redis } from "@budibase/backend-core"
|
||||||
import { User, SendEmailOpts, SMTPInnerConfig } from "@budibase/types"
|
import { User, SendEmailOpts, SMTPInnerConfig } from "@budibase/types"
|
||||||
import { configs } from "@budibase/backend-core"
|
import { configs } from "@budibase/backend-core"
|
||||||
import ical from "ical-generator"
|
import ical from "ical-generator"
|
||||||
|
@ -61,9 +61,9 @@ async function getLinkCode(
|
||||||
) {
|
) {
|
||||||
switch (purpose) {
|
switch (purpose) {
|
||||||
case EmailTemplatePurpose.PASSWORD_RECOVERY:
|
case EmailTemplatePurpose.PASSWORD_RECOVERY:
|
||||||
return getResetPasswordCode(user._id!, info)
|
return redis.passwordReset.createCode(user._id!, info)
|
||||||
case EmailTemplatePurpose.INVITATION:
|
case EmailTemplatePurpose.INVITATION:
|
||||||
return getInviteCode(email, info)
|
return redis.invite.createCode(email, info)
|
||||||
default:
|
default:
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,150 +0,0 @@
|
||||||
import { redis, utils, tenancy } from "@budibase/backend-core"
|
|
||||||
import env from "../environment"
|
|
||||||
|
|
||||||
function getExpirySecondsForDB(db: string) {
|
|
||||||
switch (db) {
|
|
||||||
case redis.utils.Databases.PW_RESETS:
|
|
||||||
// a hour
|
|
||||||
return 3600
|
|
||||||
case redis.utils.Databases.INVITATIONS:
|
|
||||||
// a week
|
|
||||||
return 604800
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let pwResetClient: any, invitationClient: any
|
|
||||||
|
|
||||||
function getClient(db: string) {
|
|
||||||
switch (db) {
|
|
||||||
case redis.utils.Databases.PW_RESETS:
|
|
||||||
return pwResetClient
|
|
||||||
case redis.utils.Databases.INVITATIONS:
|
|
||||||
return invitationClient
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeACode(db: string, value: any) {
|
|
||||||
const client = await getClient(db)
|
|
||||||
const code = utils.newid()
|
|
||||||
await client.store(code, value, getExpirySecondsForDB(db))
|
|
||||||
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 inviteCode The invite code for an invite in redis
|
|
||||||
* @param 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)
|
|
||||||
if (!value) {
|
|
||||||
throw new Error("Invalid code.")
|
|
||||||
}
|
|
||||||
if (deleteCode) {
|
|
||||||
await client.delete(code)
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function init() {
|
|
||||||
pwResetClient = new redis.Client(redis.utils.Databases.PW_RESETS)
|
|
||||||
invitationClient = new redis.Client(redis.utils.Databases.INVITATIONS)
|
|
||||||
await pwResetClient.init()
|
|
||||||
await invitationClient.init()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* make sure redis connection is closed.
|
|
||||||
*/
|
|
||||||
export async function shutdown() {
|
|
||||||
if (pwResetClient) await pwResetClient.finish()
|
|
||||||
if (invitationClient) await invitationClient.finish()
|
|
||||||
// shutdown core clients
|
|
||||||
await redis.clients.shutdown()
|
|
||||||
console.log("Redis shutdown")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Given a user ID this will store a code (that is returned) for an hour in redis.
|
|
||||||
* The user can then return this code for resetting their password (through their reset link).
|
|
||||||
* @param userId the ID of the user which is to be reset.
|
|
||||||
* @param info Info about the user/the reset process.
|
|
||||||
* @return returns the code that was stored to redis.
|
|
||||||
*/
|
|
||||||
export async function getResetPasswordCode(userId: string, info: any) {
|
|
||||||
return writeACode(redis.utils.Databases.PW_RESETS, { userId, info })
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Given a reset code this will lookup to redis, check if the code is valid and delete if required.
|
|
||||||
* @param resetCode The code provided via the email link.
|
|
||||||
* @param deleteCode If the code is used/finished with this will delete it - defaults to true.
|
|
||||||
* @return returns the user ID if it is found
|
|
||||||
*/
|
|
||||||
export async function checkResetPasswordCode(
|
|
||||||
resetCode: string,
|
|
||||||
deleteCode = true
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
return getACode(redis.utils.Databases.PW_RESETS, resetCode, deleteCode)
|
|
||||||
} catch (err) {
|
|
||||||
throw "Provided information is not valid, cannot reset password - please try again."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates an invitation code and writes it to redis - which can later be checked for user creation.
|
|
||||||
* @param email the email address which the code is being sent to (for use later).
|
|
||||||
* @param info Information to be carried along with the invitation.
|
|
||||||
* @return returns the code that was stored to redis.
|
|
||||||
*/
|
|
||||||
export async function getInviteCode(email: string, info: any) {
|
|
||||||
return writeACode(redis.utils.Databases.INVITATIONS, { email, info })
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks that the provided invite code is valid - will return the email address of user that was invited.
|
|
||||||
* @param inviteCode the invite code that was provided as part of the link.
|
|
||||||
* @param deleteCode whether or not the code should be deleted after retrieval - defaults to true.
|
|
||||||
* @return If the code is valid then an email address will be returned.
|
|
||||||
*/
|
|
||||||
export async function checkInviteCode(
|
|
||||||
inviteCode: string,
|
|
||||||
deleteCode: boolean = true
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
return getACode(redis.utils.Databases.INVITATIONS, inviteCode, deleteCode)
|
|
||||||
} catch (err) {
|
|
||||||
throw "Invitation is not valid or has expired, please request a new one."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
Get all currently available user invitations for the current tenant.
|
|
||||||
**/
|
|
||||||
export async function getInviteCodes() {
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (!env.MULTI_TENANCY) {
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
const tenantId = tenancy.getTenantId()
|
|
||||||
return results.filter(invite => tenantId === invite.info.tenantId)
|
|
||||||
}
|
|
|
@ -983,10 +983,10 @@
|
||||||
expect "^29.0.0"
|
expect "^29.0.0"
|
||||||
pretty-format "^29.0.0"
|
pretty-format "^29.0.0"
|
||||||
|
|
||||||
"@types/node-fetch@2.6.2":
|
"@types/node-fetch@2.6.4":
|
||||||
version "2.6.2"
|
version "2.6.4"
|
||||||
resolved "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.2.tgz"
|
resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.4.tgz#1bc3a26de814f6bf466b25aeb1473fa1afe6a660"
|
||||||
integrity sha512-DHqhlq5jeESLy19TYhLakJ07kNumXWjcDdxXsLUMJZ6ue8VZJj4kLPQVE/2mdHh3xZziNF1xppu5lwmS53HR+A==
|
integrity sha512-1ZX9fcN4Rvkvgv4E6PAY5WXUFWFcRWxZa3EW83UjycOB9ljJCedb2CupIP4RZMEwF/M3eTcCihbBRgwtGbg5Rg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@types/node" "*"
|
"@types/node" "*"
|
||||||
form-data "^3.0.0"
|
form-data "^3.0.0"
|
||||||
|
@ -3587,18 +3587,18 @@ node-duration@^1.0.4:
|
||||||
resolved "https://registry.npmjs.org/node-duration/-/node-duration-1.0.4.tgz"
|
resolved "https://registry.npmjs.org/node-duration/-/node-duration-1.0.4.tgz"
|
||||||
integrity sha512-eUXYNSY7DL53vqfTosggWkvyIW3bhAcqBDIlolgNYlZhianXTrCL50rlUJWD1eRqkIxMppXTfiFbp+9SjpPrgA==
|
integrity sha512-eUXYNSY7DL53vqfTosggWkvyIW3bhAcqBDIlolgNYlZhianXTrCL50rlUJWD1eRqkIxMppXTfiFbp+9SjpPrgA==
|
||||||
|
|
||||||
node-fetch@2, node-fetch@2.6.7, node-fetch@^2.6.7:
|
node-fetch@2.6.0:
|
||||||
|
version "2.6.0"
|
||||||
|
resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz"
|
||||||
|
integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==
|
||||||
|
|
||||||
|
node-fetch@2.6.7, node-fetch@^2.6.7:
|
||||||
version "2.6.7"
|
version "2.6.7"
|
||||||
resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz"
|
resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz"
|
||||||
integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==
|
integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
whatwg-url "^5.0.0"
|
whatwg-url "^5.0.0"
|
||||||
|
|
||||||
node-fetch@2.6.0:
|
|
||||||
version "2.6.0"
|
|
||||||
resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz"
|
|
||||||
integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==
|
|
||||||
|
|
||||||
node-gyp-build-optional-packages@5.0.7:
|
node-gyp-build-optional-packages@5.0.7:
|
||||||
version "5.0.7"
|
version "5.0.7"
|
||||||
resolved "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.0.7.tgz"
|
resolved "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.0.7.tgz"
|
||||||
|
@ -4893,10 +4893,10 @@ type-is@^1.6.16, type-is@^1.6.18:
|
||||||
media-typer "0.3.0"
|
media-typer "0.3.0"
|
||||||
mime-types "~2.1.24"
|
mime-types "~2.1.24"
|
||||||
|
|
||||||
typescript@4.7.3:
|
typescript@5.2.2:
|
||||||
version "4.7.3"
|
version "5.2.2"
|
||||||
resolved "https://registry.npmjs.org/typescript/-/typescript-4.7.3.tgz"
|
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.2.2.tgz#5ebb5e5a5b75f085f22bc3f8460fba308310fa78"
|
||||||
integrity sha512-WOkT3XYvrpXx4vMMqlD+8R8R37fZkjyLGlxavMc4iB8lrl8L0DeTcHbYgw/v0N/z9wAFsgBhcsF0ruoySS22mA==
|
integrity sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==
|
||||||
|
|
||||||
uid2@0.0.x:
|
uid2@0.0.x:
|
||||||
version "0.0.4"
|
version "0.0.4"
|
||||||
|
|
Loading…
Reference in New Issue