User management events

This commit is contained in:
Rory Powell 2022-04-12 12:34:36 +01:00
parent 957e90fe86
commit d99f579bf6
12 changed files with 174 additions and 53 deletions

View File

@ -3,7 +3,6 @@ exports.Events = {
USER_CREATED: "user:created",
USER_UPDATED: "user:updated",
USER_DELETED: "user:deleted",
USER_PASSWORD_FORCE_RESET: "user:password:force:reset",
// USER / PERMISSIONS
USER_PERMISSION_ADMIN_ASSIGNED: "user:admin:assigned",
@ -15,9 +14,9 @@ exports.Events = {
USER_INVITED: "user:invited",
USER_INVITED_ACCEPTED: "user:invite:accepted",
// USER / SELF
USER_SELF_UPDATED: "user:self:updated",
USER_SELF_PASSWORD_UPDATED: "user:self:password:updated",
// USER / PASSWORD
USER_PASSWORD_FORCE_RESET: "user:password:force:reset",
USER_PASSWORD_UPDATED: "user:password:updated",
USER_PASSWORD_RESET_REQUESTED: "user:password:reset:requested",
USER_PASSWORD_RESET: "user:password:reset",

View File

@ -18,12 +18,6 @@ exports.deleted = user => {
events.processEvent(Events.USER_DELETED, properties)
}
// TODO
exports.passwordForceReset = user => {
const properties = {}
events.processEvent(Events.USER_PASSWORD_FORCE_RESET, properties)
}
// PERMISSIONS
exports.permissionAdminAssigned = user => {
@ -48,39 +42,33 @@ exports.permissionBuilderRemoved = user => {
// INVITE
// TODO
exports.invited = user => {
exports.invited = userInfo => {
const properties = {}
events.processEvent(Events.USER_INVITED, properties)
}
// TODO
exports.inviteAccepted = user => {
const properties = {}
events.processEvent(Events.USER_INVITED_ACCEPTED, properties)
}
// SELF
// PASSWORD
// TODO
exports.selfUpdated = user => {
exports.passwordForceReset = user => {
const properties = {}
events.processEvent(Events.USER_SELF_UPDATED, properties)
events.processEvent(Events.USER_PASSWORD_FORCE_RESET, properties)
}
// TODO
exports.selfPasswordUpdated = user => {
exports.passwordUpdated = user => {
const properties = {}
events.processEvent(Events.USER_SELF_PASSWORD_UPDATED, properties)
events.processEvent(Events.USER_PASSWORD_UPDATED, properties)
}
// TODO
exports.passwordResetRequested = user => {
const properties = {}
events.processEvent(Events.USER_PASSWORD_RESET_REQUESTED, properties)
}
// TODO
exports.passwordReset = user => {
const properties = {}
events.processEvent(Events.USER_PASSWORD_RESET, properties)

View File

@ -80,15 +80,14 @@ jest.mock("../../../events", () => {
created: jest.fn(),
updated: jest.fn(),
deleted: jest.fn(),
passwordForceReset: jest.fn(),
permissionAdminAssigned: jest.fn(),
permissionAdminRemoved: jest.fn(),
permissionBuilderAssigned: jest.fn(),
permissionBuilderRemoved: jest.fn(),
invited: jest.fn(),
inviteAccepted: jest.fn(),
selfUpdated: jest.fn(),
selfPasswordUpdated: jest.fn(),
passwordForceReset: jest.fn(),
passwordUpdated: jest.fn(),
passwordResetRequested: jest.fn(),
passwordReset: jest.fn(),
},

View File

@ -12,8 +12,20 @@ exports.getGlobalUserByEmail = async email => {
throw "Must supply an email address to view"
}
return queryGlobalView(ViewNames.USER_BY_EMAIL, {
const response = await queryGlobalView(ViewNames.USER_BY_EMAIL, {
key: email.toLowerCase(),
include_docs: true,
})
if (response) {
if (Array.isArray(response)) {
for (let user of response) {
delete user.password
}
} else {
delete response.password
}
}
return response
}

View File

@ -112,6 +112,7 @@ export const reset = async (ctx: any) => {
user,
subject: "{{ company }} platform password reset",
})
events.user.passwordResetRequested(user)
}
} catch (err) {
console.log(err)
@ -136,6 +137,9 @@ export const resetUpdate = async (ctx: any) => {
ctx.body = {
message: "password reset successfully.",
}
// remove password from the user before sending events
delete user.password
events.user.passwordReset(user)
} catch (err) {
ctx.throw(400, "Cannot reset password.")
}

View File

@ -15,6 +15,7 @@ const { encrypt } = require("@budibase/backend-core/encryption")
const { newid } = require("@budibase/backend-core/utils")
const { users } = require("../../../sdk")
const { Cookies } = require("@budibase/backend-core/constants")
const { events } = require("@budibase/backend-core")
function newApiKey() {
return encrypt(`${getTenantId()}${SEPARATOR}${newid()}`)
@ -110,8 +111,10 @@ exports.getSelf = async ctx => {
exports.updateSelf = async ctx => {
const db = getGlobalDB()
const user = await db.get(ctx.user._id)
let passwordChange = false
if (ctx.request.body.password) {
// changing password
passwordChange = true
ctx.request.body.password = await hash(ctx.request.body.password)
// Log all other sessions out apart from the current one
await platformLogout({
@ -134,4 +137,11 @@ exports.updateSelf = async ctx => {
_id: response.id,
_rev: response.rev,
}
// remove the old password from the user before sending events
delete user.password
events.user.updated(user)
if (passwordChange) {
events.user.passwordUpdated(user)
}
}

View File

@ -8,6 +8,7 @@ const {
users: usersCore,
tenancy,
db: dbUtils,
events,
} = require("@budibase/backend-core")
export const save = async (ctx: any) => {
@ -121,6 +122,7 @@ export const invite = async (ctx: any) => {
ctx.body = {
message: "Invitation has been sent.",
}
events.user.invited(userInfo)
}
export const inviteAccept = async (ctx: any) => {
@ -128,14 +130,16 @@ export const inviteAccept = async (ctx: any) => {
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, () => {
return users.save({
ctx.body = await tenancy.doInTenant(info.tenantId, async () => {
const user = await users.save({
firstName,
lastName,
password,
email,
...info,
})
events.user.inviteAccepted(user)
return user
})
} catch (err: any) {
if (err.code === errors.codes.USAGE_LIMIT_EXCEEDED) {

View File

@ -6,7 +6,6 @@ const { events } = require("@budibase/backend-core")
const TENANT_ID = structures.TENANT_ID
describe("/api/global/auth", () => {
let code
beforeAll(async () => {
await config.beforeAll()
@ -20,16 +19,7 @@ describe("/api/global/auth", () => {
jest.clearAllMocks()
})
it("should logout", async () => {
await request
.post("/api/global/auth/logout")
.set(config.defaultHeaders())
.expect(200)
expect(events.auth.logout.mock.calls.length).toBe(1)
})
it("should be able to generate password reset email", async () => {
// initially configure settings
const requestPasswordReset = async () => {
await config.saveSmtpConfig()
await config.saveSettingsConfig()
await config.createUser()
@ -40,16 +30,36 @@ describe("/api/global/auth", () => {
})
.expect("Content-Type", /json/)
.expect(200)
const emailCall = sendMailMock.mock.calls[0][0]
const parts = emailCall.html.split(`http://localhost:10000/builder/auth/reset?code=`)
const code = parts[1].split("\"")[0].split("&")[0]
return { code, res }
}
it("should logout", async () => {
await request
.post("/api/global/auth/logout")
.set(config.defaultHeaders())
.expect(200)
expect(events.auth.logout.mock.calls.length).toBe(1)
})
it("should be able to generate password reset email", async () => {
const { res, code } = await requestPasswordReset()
const user = await config.getUser("test@test.com")
expect(res.body).toEqual({ message: "Please check your email for a reset link." })
expect(sendMailMock).toHaveBeenCalled()
const emailCall = sendMailMock.mock.calls[0][0]
// after this URL there should be a code
const parts = emailCall.html.split(`http://localhost:10000/builder/auth/reset?code=`)
code = parts[1].split("\"")[0].split("&")[0]
expect(code).toBeDefined()
expect(events.user.passwordResetRequested).toBeCalledTimes(1)
expect(events.user.passwordResetRequested).toBeCalledWith(user)
})
it("should allow resetting user password with code", async () => {
const { code } = await requestPasswordReset()
const user = await config.getUser("test@test.com")
const res = await request
.post(`/api/global/auth/${TENANT_ID}/reset/update`)
.send({
@ -59,6 +69,8 @@ describe("/api/global/auth", () => {
.expect("Content-Type", /json/)
.expect(200)
expect(res.body).toEqual({ message: "password reset successfully." })
expect(events.user.passwordReset).toBeCalledTimes(1)
expect(events.user.passwordReset).toBeCalledWith(user)
})
describe("oidc", () => {

View File

@ -0,0 +1,58 @@
jest.mock("nodemailer")
const { config, request } = require("../../../tests")
const { events, utils } = require("@budibase/backend-core")
describe("/api/global/self", () => {
beforeAll(async () => {
await config.beforeAll()
})
afterAll(async () => {
await config.afterAll()
})
beforeEach(() => {
jest.clearAllMocks()
})
const updateSelf = async (user) => {
const res = await request
.post(`/api/global/self`)
.send(user)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
return res
}
describe("update", () => {
it("should update self", async () => {
const user = await config.createUser()
const res = await updateSelf(user)
delete user.password
expect(res.body._id).toBe(user._id)
expect(events.user.updated).toBeCalledTimes(1)
expect(events.user.updated).toBeCalledWith(user)
expect(events.user.passwordUpdated).not.toBeCalled()
})
it("should update password", async () => {
const user = await config.createUser()
const password = "newPassword"
user.password = password
const res = await updateSelf(user)
delete user.password
expect(res.body._id).toBe(user._id)
expect(events.user.updated).toBeCalledTimes(1)
expect(events.user.updated).toBeCalledWith(user)
expect(events.user.passwordUpdated).toBeCalledTimes(1)
expect(events.user.passwordUpdated).toBeCalledWith(user)
})
})
})

View File

@ -4,7 +4,6 @@ const sendMailMock = mocks.email.mock()
const { events } = require("@budibase/backend-core")
describe("/api/global/users", () => {
let code
beforeAll(async () => {
await config.beforeAll()
@ -14,8 +13,7 @@ describe("/api/global/users", () => {
await config.afterAll()
})
it("should be able to generate an invitation", async () => {
// initially configure settings
const sendUserInvite = async () => {
await config.saveSmtpConfig()
await config.saveSettingsConfig()
const res = await request
@ -26,16 +24,27 @@ describe("/api/global/users", () => {
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body).toEqual({ message: "Invitation has been sent." })
expect(sendMailMock).toHaveBeenCalled()
const emailCall = sendMailMock.mock.calls[0][0]
// after this URL there should be a code
const parts = emailCall.html.split("http://localhost:10000/builder/invite?code=")
code = parts[1].split("\"")[0].split("&")[0]
const code = parts[1].split("\"")[0].split("&")[0]
return { code, res }
}
it("should be able to generate an invitation", async () => {
const { code, res } = await sendUserInvite()
expect(res.body).toEqual({ message: "Invitation has been sent." })
expect(sendMailMock).toHaveBeenCalled()
expect(code).toBeDefined()
expect(events.user.invited).toBeCalledTimes(1)
expect(events.user.invited).toBeCalledWith({ tenantId: structures.TENANT_ID })
})
it("should be able to create new user from invite", async () => {
const { code } = await sendUserInvite()
const res = await request
.post(`/api/global/users/invite/accept`)
.send({
@ -48,6 +57,8 @@ describe("/api/global/users", () => {
const user = await config.getUser("invite@test.com")
expect(user).toBeDefined()
expect(user._id).toEqual(res.body._id)
expect(events.user.inviteAccepted).toBeCalledTimes(1)
expect(events.user.inviteAccepted).toBeCalledWith(res.body)
})
const createUser = async (user) => {
@ -70,7 +81,7 @@ describe("/api/global/users", () => {
.send(user)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
// .expect(200)
.expect(200)
return res.body
}
@ -149,6 +160,23 @@ describe("/api/global/users", () => {
expect(events.user.updated).toBeCalledTimes(1)
expect(events.user.permissionBuilderAssigned).not.toBeCalled()
expect(events.user.permissionAdminAssigned).not.toBeCalled()
expect(events.user.passwordForceReset).not.toBeCalled()
})
it("should be able to force reset password", async () => {
let user = structures.users.user({ email: "basic-password-update@test.com" })
await createUser(user)
jest.clearAllMocks()
user.forceResetPassword = true
user.password = "tempPassword"
await updateUser(user)
expect(events.user.created).not.toBeCalled()
expect(events.user.updated).toBeCalledTimes(1)
expect(events.user.permissionBuilderAssigned).not.toBeCalled()
expect(events.user.permissionAdminAssigned).not.toBeCalled()
expect(events.user.passwordForceReset).toBeCalledTimes(1)
})
it("should be able to update a basic user to an admin user", async () => {

View File

@ -52,6 +52,14 @@ export const handleSaveEvents = (user: any, existingUser: any) => {
if (isRemovingAdmin(user, existingUser)) {
events.user.permissionAdminRemoved(user)
}
if (
!existingUser.forceResetPassword &&
user.forceResetPassword &&
user.password
) {
events.user.passwordForceReset(user)
}
} else {
events.user.created(user)
}

View File

@ -1,7 +1,6 @@
import env from "../../environment"
import { quotas } from "@budibase/pro"
import * as apps from "../../utilities/appService"
const { events } = require("@budibase/backend-core")
import * as eventHelpers from "./events"
const {
@ -125,7 +124,7 @@ export const save = async (
const putUserFn = () => {
return db.put(user)
}
if (await eventHelpers.isAddingBuilder(user, dbUser)) {
if (eventHelpers.isAddingBuilder(user, dbUser)) {
response = await quotas.addDeveloper(putUserFn)
} else {
response = await putUserFn()