@flowfuse/flowfuse
Version:
An open source low-code development platform
266 lines (248 loc) • 9.71 kB
JavaScript
/**
* Team Invitations api routes
*
* - /api/v1/teams/:teamId/invitations
*
* By the time these handlers are invoked, :teamApi will have been validated
* and 404'd if it doesn't exist. `request.team` will contain the team object
*
* @namespace teamInvitations
* @memberof forge.routes.api
*/
const { getCanonicalEmail } = require('../../db/utils')
const { TeamRoles, Roles } = require('../../lib/roles')
module.exports = async function (app) {
// All routes require user to be owner of team
app.addHook('preHandler', app.needsPermission('team:user:invite'))
app.get('/', {
schema: {
summary: 'Get a list of the teams invitations',
tags: ['Team Invitations'],
params: {
type: 'object',
properties: {
teamId: { type: 'string' }
}
},
response: {
200: {
type: 'object',
properties: {
// meta: { $ref: 'PaginationMeta' },
count: { type: 'number' },
invitations: { $ref: 'InvitationList' }
}
},
'4xx': {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
const invitations = await app.db.models.Invitation.forTeam(request.team)
const result = app.db.views.Invitation.invitationList(invitations)
reply.send({
meta: {}, // For future pagination
count: result.length,
invitations: result
})
})
/**
* Create an invitation
* POST [/api/v1/teams/:teamId/invitations]/
*/
app.post('/', {
config: {
rateLimit: app.config.rate_limits ? { max: 5, timeWindow: 30000 } : false
},
schema: {
summary: 'Create an invitation',
tags: ['Team Invitations'],
params: {
type: 'object',
properties: {
teamId: { type: 'string' }
}
},
body: {
type: 'object',
properties: {
user: { type: 'string' },
role: { type: 'number' }
},
required: ['user']
},
response: {
200: {
type: 'object',
properties: {
status: { type: 'string' },
code: { type: 'string' },
error: { type: 'object', additionalProperties: true }
}
},
'4xx': {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
const userDetails = request.body.user.split(',').map(u => u.trim()).filter(Boolean)
// 1st check there are any users to invite
if (userDetails.length === 0) {
const result = {
status: 'error',
code: 'invitation_failed',
error: 'no users specified'
}
reply.code(400).send(result)
await app.auditLog.Team.team.user.invited(request.session.User, result, request.team, null, request.body.role || Roles.Member)
return
}
// separate user names from emails, deduplicate both lists then recombine
// use a regex to determine if the user is NOT email address
const namesOnly = userDetails.filter(u => !u.match(/^[^@]+@[^@]+$/))
const namesOnlyDeduplicated = [...new Set(namesOnly.map(u => u.trim().toLowerCase()))].map(u => namesOnly.find(n => n.trim().toLowerCase() === u))
// use a regex to determine if the user is an email address
const emailsOnly = userDetails.filter(u => u.match(/^[^@]+@[^@]+$/))
// Deduplicate the list based on the canonical email, but keep the as-provided
// email in the list
const emailsOnlyDeduplicated = {}
emailsOnly.forEach(email => {
const canonicalEmail = getCanonicalEmail(email)
if (!emailsOnlyDeduplicated[canonicalEmail]) {
emailsOnlyDeduplicated[canonicalEmail] = email
}
})
// recombine the deduplicated lists
const userDetailsDeduplicated = [...namesOnlyDeduplicated, ...Object.values(emailsOnlyDeduplicated)]
// limit to 5 invites at a time
if (userDetailsDeduplicated.length > 5) {
const result = {
status: 'error',
code: 'too_many_invites',
error: 'maximum 5 invites at a time'
}
reply.code(429).send(result)
await app.auditLog.Team.team.user.invited(request.session.User, result, request.team, null, request.body.role || Roles.Member)
return
}
const role = request.body.role || Roles.Member
if (!TeamRoles.includes(role)) {
reply.code(400).send({ code: 'invalid_team_role', error: 'invalid team role' })
return
}
let invites = []
try {
invites = await app.db.controllers.Invitation.createInvitations(request.session.User, request.team, userDetailsDeduplicated, role)
} catch (err) {
reply.code(400).send({ code: 'invitation_failed', error: err.message })
return
}
const result = {
status: 'okay',
message: {}
}
let errorCount = 0
for (const [user, invite] of Object.entries(invites)) {
if (typeof invite === 'string') {
errorCount++
result.message[user] = invite
} else {
try {
// controllers.Invitation.createInvitations will have already
// rejected external requests if team:user:invite:external set to false
await app.db.controllers.Invitation.sendNotification(invite, user, request.team, role)
} catch (err) {
errorCount++
result.message[user] = 'Error sending invitation email'
}
}
}
if (errorCount > 0) {
result.code = 'invitation_failed'
result.error = result.message
delete result.status
await app.auditLog.Team.team.user.invited(request.session.User, result, request.team, null, role)
}
delete result.message
reply.send(result)
})
/**
* Delete an invitation
* DELETE [/api/v1/teams/:teamId/invitations]/:invitationId
*/
app.delete('/:invitationId', {
schema: {
summary: 'Delete an invitation',
tags: ['Team Invitations'],
params: {
type: 'object',
properties: {
teamId: { type: 'string' },
invitationId: { type: 'string' }
}
},
response: {
200: {
$ref: 'APIStatus'
},
'4xx': {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
const invitation = await app.db.models.Invitation.byId(request.params.invitationId)
if (invitation && invitation.teamId === request.team.id) {
const role = invitation.role || Roles.Member
const invitedUser = app.auditLog.formatters.userObject(invitation.external ? invitation : invitation.invitee)
if (!invitation.external) {
const notificationReference = `team-invite:${invitation.hashid}`
await app.notifications.remove(invitation.invitee, notificationReference)
}
await invitation.destroy()
await app.auditLog.Team.team.user.uninvited(request.session.User, null, request.team, invitedUser, role)
reply.send({ status: 'okay' })
} else {
reply.code(404).send({ code: 'not_found', error: 'Not Found' })
}
})
/**
* Resend an invitation
* POST [/api/v1/teams/:teamId/invitations]/:invitationId
*/
app.post('/:invitationId', {
schema: {
summary: 'Resend an invitation',
tags: ['Team Invitations'],
params: {
type: 'object',
properties: {
teamId: { type: 'string' },
invitationId: { type: 'string' }
}
},
response: {
200: {
$ref: 'Invitation'
},
'4xx': {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
const invitation = await app.db.models.Invitation.byId(request.params.invitationId)
if (invitation && invitation.teamId === request.team.id) {
const role = invitation.role || Roles.Member
const invitedUser = app.auditLog.formatters.userObject(invitation.external ? invitation : invitation.invitee)
await app.db.models.Invitation.extendExpirationDate(invitation.id)
await invitation.reload()
await app.db.controllers.Invitation.sendNotification(invitation, invitedUser, request.team, role, true)
reply.send(app.db.views.Invitation.invitation(invitation))
} else {
reply.code(404).send({ code: 'not_found', error: 'Not Found' })
}
})
}