Merge pull request #1545 from Budibase/fix/user-setup-builder-admin

Ability to configure invited users as admins/builders
This commit is contained in:
Michael Drury 2021-05-24 20:40:32 +01:00 committed by GitHub
commit b5da856c64
8 changed files with 124 additions and 27 deletions

View File

@ -5,6 +5,8 @@
Select, Select,
ModalContent, ModalContent,
notifications, notifications,
Toggle,
Label,
} from "@budibase/bbui" } from "@budibase/bbui"
import { createValidationStore, emailValidator } from "helpers/validation" import { createValidationStore, emailValidator } from "helpers/validation"
import { users } from "stores/portal" import { users } from "stores/portal"
@ -13,12 +15,12 @@
const options = ["Email onboarding", "Basic onboarding"] const options = ["Email onboarding", "Basic onboarding"]
let selected = options[0] let selected = options[0]
let builder, admin
const [email, error, touched] = createValidationStore("", emailValidator) const [email, error, touched] = createValidationStore("", emailValidator)
async function createUserFlow() { async function createUserFlow() {
const res = await users.invite($email) const res = await users.invite({ email: $email, builder, admin })
console.log(res)
if (res.status) { if (res.status) {
notifications.error(res.message) notifications.error(res.message)
} else { } else {
@ -56,4 +58,23 @@
placeholder="john@doe.com" placeholder="john@doe.com"
label="Email" label="Email"
/> />
<div>
<div class="toggle">
<Label size="L">Development access</Label>
<Toggle text="" bind:value={builder} />
</div>
<div class="toggle">
<Label size="L">Administration access</Label>
<Toggle text="" bind:value={admin} />
</div>
</div>
</ModalContent> </ModalContent>
<style>
.toggle {
display: grid;
grid-template-columns: 78% 1fr;
align-items: center;
width: 50%;
}
</style>

View File

@ -1,13 +1,22 @@
<script> <script>
import { ModalContent, Body, Input, notifications } from "@budibase/bbui" import {
ModalContent,
Body,
Input,
notifications,
Toggle,
Label,
} from "@budibase/bbui"
import { createValidationStore, emailValidator } from "helpers/validation" import { createValidationStore, emailValidator } from "helpers/validation"
import { users } from "stores/portal" import { users } from "stores/portal"
const [email, error, touched] = createValidationStore("", emailValidator) const [email, error, touched] = createValidationStore("", emailValidator)
const password = Math.random().toString(36).substr(2, 20) const password = Math.random().toString(36).substr(2, 20)
let builder = false,
admin = false
async function createUser() { async function createUser() {
const res = await users.create({ email: $email, password }) const res = await users.create({ email: $email, password, builder, admin })
if (res.status) { if (res.status) {
notifications.error(res.message) notifications.error(res.message)
} else { } else {
@ -37,4 +46,23 @@
error={$touched && $error} error={$touched && $error}
/> />
<Input disabled label="Password" value={password} /> <Input disabled label="Password" value={password} />
<div>
<div class="toggle">
<Label size="L">Development access</Label>
<Toggle text="" bind:value={builder} />
</div>
<div class="toggle">
<Label size="L">Administration access</Label>
<Toggle text="" bind:value={admin} />
</div>
</div>
</ModalContent> </ModalContent>
<style>
.toggle {
display: grid;
grid-template-columns: 78% 1fr;
align-items: center;
width: 50%;
}
</style>

View File

@ -11,10 +11,22 @@ export function createUsersStore() {
set(json) set(json)
} }
async function invite(email) { async function invite({ email, builder, admin }) {
const response = await api.post(`/api/admin/users/invite`, { email }) const body = { email, userInfo: {} }
if (admin) {
body.userInfo.admin = {
global: true,
}
}
if (builder) {
body.userInfo.builder = {
global: true,
}
}
const response = await api.post(`/api/admin/users/invite`, body)
return await response.json() return await response.json()
} }
async function acceptInvite(inviteCode, password) { async function acceptInvite(inviteCode, password) {
const response = await api.post("/api/admin/users/invite/accept", { const response = await api.post("/api/admin/users/invite/accept", {
inviteCode, inviteCode,
@ -23,14 +35,20 @@ export function createUsersStore() {
return await response.json() return await response.json()
} }
async function create({ email, password }) { async function create({ email, password, admin, builder }) {
const response = await api.post("/api/admin/users", { const body = {
email, email,
password, password,
builder: { global: true },
roles: {}, roles: {},
}) }
init() if (builder) {
body.builder = { global: true }
}
if (admin) {
body.admin = { global: true }
}
const response = await api.post("/api/admin/users", body)
await init()
return await response.json() return await response.json()
} }
@ -43,8 +61,7 @@ export function createUsersStore() {
async function save(data) { async function save(data) {
try { try {
const res = await post(`/api/admin/users`, data) const res = await post(`/api/admin/users`, data)
const json = await res.json() return await res.json()
return json
} catch (error) { } catch (error) {
console.log(error) console.log(error)
return error return error

View File

@ -167,13 +167,14 @@ exports.find = async ctx => {
} }
exports.invite = async ctx => { exports.invite = async ctx => {
const { email } = ctx.request.body const { email, userInfo } = ctx.request.body
const existing = await getGlobalUserByEmail(email) const existing = await getGlobalUserByEmail(email)
if (existing) { if (existing) {
ctx.throw(400, "Email address already in use.") ctx.throw(400, "Email address already in use.")
} }
await sendEmail(email, EmailTemplatePurpose.INVITATION, { await sendEmail(email, EmailTemplatePurpose.INVITATION, {
subject: "{{ company }} platform invitation", subject: "{{ company }} platform invitation",
info: userInfo,
}) })
ctx.body = { ctx.body = {
message: "Invitation has been sent.", message: "Invitation has been sent.",
@ -183,13 +184,15 @@ exports.invite = async ctx => {
exports.inviteAccept = async ctx => { exports.inviteAccept = async ctx => {
const { inviteCode, password, firstName, lastName } = ctx.request.body const { inviteCode, password, firstName, lastName } = ctx.request.body
try { try {
const email = await checkInviteCode(inviteCode) // info is an extension of the user object that was stored by admin
const { email, info } = await checkInviteCode(inviteCode)
// only pass through certain props for accepting // only pass through certain props for accepting
ctx.request.body = { ctx.request.body = {
firstName, firstName,
lastName, lastName,
password, password,
email, email,
...info,
} }
// this will flesh out the body response // this will flesh out the body response
await exports.save(ctx) await exports.save(ctx)

View File

@ -47,6 +47,7 @@ function buildInviteValidation() {
// prettier-ignore // prettier-ignore
return joiValidator.body(Joi.object({ return joiValidator.body(Joi.object({
email: Joi.string().required(), email: Joi.string().required(),
userInfo: Joi.object().optional(),
}).required()) }).required())
} }

View File

@ -9,6 +9,7 @@ const { passport } = require("@budibase/auth").auth
const logger = require("koa-pino-logger") const logger = require("koa-pino-logger")
const http = require("http") const http = require("http")
const api = require("./api") const api = require("./api")
const redis = require("./utilities/redis")
const app = new Koa() const app = new Koa()
@ -34,10 +35,16 @@ app.use(api.routes())
const server = http.createServer(app.callback()) const server = http.createServer(app.callback())
destroyable(server) destroyable(server)
server.on("close", () => console.log("Server Closed")) server.on("close", async () => {
if (env.isProd()) {
console.log("Server Closed")
}
await redis.shutdown()
})
module.exports = server.listen(parseInt(env.PORT || 4002), async () => { module.exports = 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 redis.init()
}) })
process.on("uncaughtException", err => { process.on("uncaughtException", err => {

View File

@ -46,12 +46,12 @@ function createSMTPTransport(config) {
return nodemailer.createTransport(options) return nodemailer.createTransport(options)
} }
async function getLinkCode(purpose, email, user) { async function getLinkCode(purpose, email, user, info = null) {
switch (purpose) { switch (purpose) {
case EmailTemplatePurpose.PASSWORD_RECOVERY: case EmailTemplatePurpose.PASSWORD_RECOVERY:
return getResetPasswordCode(user._id) return getResetPasswordCode(user._id)
case EmailTemplatePurpose.INVITATION: case EmailTemplatePurpose.INVITATION:
return getInviteCode(email) return getInviteCode(email, info)
default: default:
return null return null
} }
@ -136,13 +136,14 @@ exports.isEmailConfigured = async (groupId = null) => {
* @param {string|undefined} from If sending from an address that is not what is configured in the SMTP config. * @param {string|undefined} from If sending from an address that is not what is configured in the SMTP config.
* @param {string|undefined} contents If sending a custom email then can supply contents which will be added to it. * @param {string|undefined} contents If sending a custom email then can supply contents which will be added to it.
* @param {string|undefined} subject A custom subject can be specified if the config one is not desired. * @param {string|undefined} subject A custom subject can be specified if the config one is not desired.
* @param {object|undefined} info Pass in a structure of information to be stored alongside the invitation.
* @return {Promise<object>} returns details about the attempt to send email, e.g. if it is successful; based on * @return {Promise<object>} returns details about the attempt to send email, e.g. if it is successful; based on
* nodemailer response. * nodemailer response.
*/ */
exports.sendEmail = async ( exports.sendEmail = async (
email, email,
purpose, purpose,
{ groupId, user, from, contents, subject } = {} { groupId, user, from, contents, subject, info } = {}
) => { ) => {
const db = new CouchDB(GLOBAL_DB) const db = new CouchDB(GLOBAL_DB)
let config = (await getSmtpConfiguration(db, groupId)) || {} let config = (await getSmtpConfiguration(db, groupId)) || {}
@ -151,7 +152,7 @@ exports.sendEmail = async (
} }
const transport = createSMTPTransport(config) const transport = createSMTPTransport(config)
// if there is a link code needed this will retrieve it // if there is a link code needed this will retrieve it
const code = await getLinkCode(purpose, email, user) const code = await getLinkCode(purpose, email, user, info)
const context = await getSettingsTemplateContext(purpose, code) const context = await getSettingsTemplateContext(purpose, code)
const message = { const message = {
from: from || config.from, from: from || config.from,

View File

@ -12,15 +12,21 @@ function getExpirySecondsForDB(db) {
} }
} }
async function getClient(db) { let pwResetClient, invitationClient
return await new Client(db).init()
function getClient(db) {
switch (db) {
case utils.Databases.PW_RESETS:
return pwResetClient
case utils.Databases.INVITATIONS:
return invitationClient
}
} }
async function writeACode(db, value) { async function writeACode(db, value) {
const client = await getClient(db) const client = await getClient(db)
const code = newid() const code = newid()
await client.store(code, value, getExpirySecondsForDB(db)) await client.store(code, value, getExpirySecondsForDB(db))
client.finish()
return code return code
} }
@ -33,10 +39,22 @@ async function getACode(db, code, deleteCode = true) {
if (deleteCode) { if (deleteCode) {
await client.delete(code) await client.delete(code)
} }
client.finish()
return value return value
} }
exports.init = async () => {
pwResetClient = await new Client(utils.Databases.PW_RESETS).init()
invitationClient = await new Client(utils.Databases.PW_RESETS).init()
}
/**
* make sure redis connection is closed.
*/
exports.shutdown = async () => {
await pwResetClient.finish()
await invitationClient.finish()
}
/** /**
* Given a user ID this will store a code (that is returned) for an hour in redis. * 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). * The user can then return this code for resetting their password (through their reset link).
@ -64,17 +82,18 @@ exports.checkResetPasswordCode = async (resetCode, deleteCode = true) => {
/** /**
* Generates an invitation code and writes it to redis - which can later be checked for user creation. * Generates an invitation code and writes it to redis - which can later be checked for user creation.
* @param {string} email the email address which the code is being sent to (for use later). * @param {string} email the email address which the code is being sent to (for use later).
* @param {object|null} info Information to be carried along with the invitation.
* @return {Promise<string>} returns the code that was stored to redis. * @return {Promise<string>} returns the code that was stored to redis.
*/ */
exports.getInviteCode = async email => { exports.getInviteCode = async (email, info) => {
return writeACode(utils.Databases.INVITATIONS, email) return writeACode(utils.Databases.INVITATIONS, { email, info })
} }
/** /**
* Checks that the provided invite code is valid - will return the email address of user that was invited. * Checks that the provided invite code is valid - will return the email address of user that was invited.
* @param {string} inviteCode the invite code that was provided as part of the link. * @param {string} inviteCode the invite code that was provided as part of the link.
* @param {boolean} deleteCode whether or not the code should be deleted after retrieval - defaults to true. * @param {boolean} deleteCode whether or not the code should be deleted after retrieval - defaults to true.
* @return {Promise<string>} If the code is valid then an email address will be returned. * @return {Promise<object>} If the code is valid then an email address will be returned.
*/ */
exports.checkInviteCode = async (inviteCode, deleteCode = true) => { exports.checkInviteCode = async (inviteCode, deleteCode = true) => {
try { try {