@flowfuse/flowfuse
Version:
An open source low-code development platform
382 lines (369 loc) • 16.3 kB
JavaScript
/**
* A User
* @namespace forge.db.models.User
*/
const { DataTypes, Op, fn, col, where } = require('sequelize')
const { Roles } = require('../../lib/roles.js')
const { hash, generateUserAvatar, buildPaginationSearchClause } = require('../utils')
module.exports = {
name: 'User',
schema: {
username: { type: DataTypes.STRING, allowNull: false, unique: true },
name: { type: DataTypes.STRING, validate: { not: /:\/\// } },
email: { type: DataTypes.STRING, unique: true, validate: { isEmail: true } },
email_verified: { type: DataTypes.BOOLEAN, defaultValue: false },
sso_enabled: { type: DataTypes.BOOLEAN, defaultValue: false },
mfa_enabled: { type: DataTypes.BOOLEAN, defaultValue: false },
password: {
type: DataTypes.STRING,
set (value) {
if (value.length < 8) {
throw new Error('Password too short')
}
this.setDataValue('password', hash(value))
}
},
password_expired: { type: DataTypes.BOOLEAN, defaultValue: false },
admin: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false },
avatar: {
type: DataTypes.STRING,
get () {
const avatar = this.getDataValue('avatar')
if (avatar) {
return `${process.env.FLOWFORGE_BASE_URL}${avatar}`
} else {
return avatar
}
}
},
tcs_accepted: { type: DataTypes.DATE, allowNull: true },
suspended: {
type: DataTypes.BOOLEAN,
defaultValue: false
}
},
indexes: [
{ name: 'user_username_lower_unique', fields: [fn('lower', col('username'))], unique: true },
{ name: 'user_email_lower_unique', fields: [fn('lower', col('email'))], unique: true }
],
scopes: {
admins: { where: { admin: true } }
},
hooks: function (M, app) {
return {
beforeCreate: async (user, options) => {
// if the product is licensed, we permit overage
const isLicensed = app.license.active()
if (isLicensed !== true) {
const { users } = await app.license.usage('users')
if (users.count >= users.limit) {
throw new Error('license limit reached')
}
}
if (!user.avatar) {
user.avatar = generateUserAvatar(user.name || user.username)
}
if (!user.name) {
user.name = user.username
}
},
afterCreate: async (user, options) => {
const { users } = await app.license.usage('users')
if (users.count > users.limit) {
await app.auditLog.Platform.platform.license.overage('system', null, users)
}
},
beforeUpdate: async (user) => {
if (user._previousDataValues.admin === true && user.admin === false) {
const currentAdmins = await app.db.models.User.scope('admins').findAll()
if (currentAdmins.length <= 1) {
throw new Error('Cannot remove last Admin user')
}
}
if (user.avatar.startsWith(`${process.env.FLOWFORGE_BASE_URL}/avatar/`)) {
user.avatar = generateUserAvatar(user.name || user.username)
}
},
beforeDestroy: async (user, opts) => {
// determine if this user is an admin whether they are the only admin
// throw an error if they are the only admin as we dont want to orphan platform
if (user.admin) {
const adminCount = await app.db.models.User.scope('admins').count()
// const adminCount = (await app.forge.db.models.User.admins()).length
if (adminCount <= 1) {
throw new Error('Cannot delete the last platform administrator')
}
}
// determine if this user owns any teams
// throw an error if we would orphan any teams
const teams = await app.db.models.Team.forUser(user)
const teamBlockers = []
const ownedTeams = []
for (const team of teams) {
const owners = await team.Team.getOwners()
const isOwner = owners.find((owner) => owner.id === user.id)
if (isOwner && owners.length <= 1) {
const instanceCount = await team.Team.instanceCount()
const deviceCount = await team.Team.deviceCount()
const members = await team.Team.memberCount()
ownedTeams.push(team.Team)
// throw error if the team has other members assigned to it
if (members > 1) {
teamBlockers.push(`Team ${team.Team.name} which is being deleted alongside your account still has users in it.`)
}
// throw error if the team has remaining instances assigned to it
if (instanceCount > 0) {
teamBlockers.push(`Team ${team.Team.name} which is being deleted alongside your account still has instances assigned to it.`)
}
// throw error if the team has remaining devices assigned to it
if (deviceCount > 0) {
teamBlockers.push(`Team ${team.Team.name} which is being deleted alongside your account still has devices assigned to it.`)
}
}
}
if (teamBlockers.length) {
throw new Error(teamBlockers[0])
}
// delete remaining owned teams
for (const ownedTeam of ownedTeams) {
await ownedTeam.destroy()
}
// Need to do this in beforeDestroy as the Session.UserId field
// is set to NULL when user is deleted.
// TODO: modify cascade delete relationship between the tables
await M.Session.destroy({
where: {
UserId: user.id
}
})
await M.Invitation.destroy({
where: {
[Op.or]: [{ invitorId: user.id }, { inviteeId: user.id }]
}
})
await M.AccessToken.destroy({
where: {
ownerType: 'user',
ownerId: '' + user.id
}
})
await M.AccessToken.destroy({
where: {
ownerType: 'npm',
ownerId: {
[Op.like]: user.username
}
}
})
}
}
},
associations: function (M) {
this.belongsToMany(M.Team, { through: M.TeamMember })
this.hasMany(M.TeamMember)
this.hasMany(M.Session)
this.hasMany(M.Invitation, { foreignKey: 'invitorId' })
this.hasMany(M.Invitation, { foreignKey: 'inviteeId' })
this.belongsTo(M.Team, { as: 'defaultTeam' })
},
finders: function (M, app) {
return {
static: {
admins: async () => {
return this.scope('admins').findAll()
},
byId: async (id) => {
if (typeof id === 'string') {
id = M.User.decodeHashid(id)
}
return this.findOne({
where: { id },
include: {
model: M.Team,
attributes: ['name'],
through: {
attributes: ['role']
}
}
})
},
byUsername: async (username) => {
return this.findOne({
where: where(
fn('lower', col('username')),
username.toLowerCase()
),
include: {
model: M.Team,
attributes: ['name'],
through: {
attributes: ['role']
}
}
})
},
byEmail: async (email) => {
return this.findOne({
where: where(
fn('lower', col('email')),
email.toLowerCase()
),
include: {
model: M.Team,
attributes: ['name'],
through: {
attributes: ['role']
}
}
})
},
byName: async (name) => {
return this.findOne({
where: { name },
include: {
model: M.Team,
attributes: ['name'],
through: {
attributes: ['role']
}
}
})
},
byUsernameOrEmail: async (name) => {
return this.findOne({
where: where(
fn('lower', col(/.+@.+/.test(name) ? 'email' : 'username')),
name.toLowerCase()
),
include: {
model: M.Team,
attributes: ['name'],
through: {
attributes: ['role']
}
}
})
},
inTeam: async (teamHashId) => {
const teamId = M.Team.decodeHashid(teamHashId)
return M.User.findAll({
include: {
model: M.Team,
attributes: ['name'],
where: { id: teamId },
through: {
attributes: ['role']
}
}
})
},
getAll: async (pagination = {}, where = {}) => {
const limit = parseInt(pagination.limit) || 1000
if (pagination.cursor) {
pagination.cursor = M.User.decodeHashid(pagination.cursor)
}
const [rows, count] = await Promise.all([
this.findAll({
where: buildPaginationSearchClause(pagination, where, ['User.username', 'User.name', 'User.email']),
order: [['id', 'ASC']],
limit
}),
this.count({ where })
])
return {
meta: {
next_cursor: rows.length === limit ? rows[rows.length - 1].hashid : undefined
},
count,
users: rows
}
},
/**
* Get users with a particular role
* @param {Array} roles An array of valid user roles
* @param {Object} options Options
* @param {Boolean} options.count only return a count of results
* @param {Boolean} options.summary whether to return a limited user object that only contains id: default false
* @param {Array} options.teamTypes limit to teams of certain types
* @param {Array} options.billing array of billing states to include
* @returns Array of users who have at least one of the specific roles, or a count
*/
byTeamRole: async (roles = [], options) => {
options = {
summary: false,
count: false,
...options
}
let attributes
if (options.summary) {
attributes = ['id']
}
const includesAdmins = roles.includes(Roles.Admin)
const where = {
[Op.or]: [
includesAdmins ? { admin: true } : {},
{ '$TeamMembers.role$': { [Op.in]: roles } }
]
}
const query = {
where,
include: {
model: M.TeamMember,
attributes: ['role'],
include: {
model: M.Team,
attributes: ['suspended', 'TeamTypeId'],
where: {
// Never include suspended teams
suspended: false
}
}
}
}
if (options.teamTypes) {
query.include.include.where.TeamTypeId = { [Op.in]: options.teamTypes }
if (options.billing) {
query.include.include.include = {
model: app.db.models.Subscription,
attributes: ['status'],
where: {
status: { [Op.in]: options.billing.map(opt => opt.toLowerCase()) }
}
}
}
}
if (!options.count) {
query.attributes = attributes
return M.User.findAll(query)
} else {
// Must set distinct otherwise Model.count will include
// users in multiple teams multiple times.
query.distinct = true
return M.User.count(query)
}
}
},
instance: {
// get the team membership for the given team
// `teamId` can be either a number (the raw id) or a string (the hashid).
// TODO: standardize on using hashids externally
getTeamMembership: async function (teamId, includeTeam) {
return M.TeamMember.getTeamMembership(this.id, teamId, includeTeam)
},
getTeamsOwned: async function () {
return M.TeamMember.getTeamsOwnedBy(this.id)
},
getTeamMemberships: async function (includeTeam = false) {
return M.TeamMember.getTeamsForUser(this.id, includeTeam)
},
teamCount: async function () {
return M.TeamMember.count({
where: {
UserId: this.id
}
})
}
}
}
}
}