|
import bcrypt from 'bcrypt'; |
|
import { DateTime } from 'luxon'; |
|
import crypto from 'node:crypto'; |
|
|
|
import appConfig from '../config/app.js'; |
|
import { hasValidLicense } from '../helpers/license.ee.js'; |
|
import userAbility from '../helpers/user-ability.js'; |
|
import createAuthTokenByUserId from '../helpers/create-auth-token-by-user-id.js'; |
|
import Base from './base.js'; |
|
import App from './app.js'; |
|
import AccessToken from './access-token.js'; |
|
import Connection from './connection.js'; |
|
import Config from './config.js'; |
|
import Execution from './execution.js'; |
|
import Flow from './flow.js'; |
|
import Identity from './identity.ee.js'; |
|
import Permission from './permission.js'; |
|
import Role from './role.js'; |
|
import Step from './step.js'; |
|
import Subscription from './subscription.ee.js'; |
|
import UsageData from './usage-data.ee.js'; |
|
import Billing from '../helpers/billing/index.ee.js'; |
|
|
|
class User extends Base { |
|
static tableName = 'users'; |
|
|
|
static jsonSchema = { |
|
type: 'object', |
|
required: ['fullName', 'email'], |
|
|
|
properties: { |
|
id: { type: 'string', format: 'uuid' }, |
|
fullName: { type: 'string', minLength: 1 }, |
|
email: { type: 'string', format: 'email', minLength: 1, maxLength: 255 }, |
|
password: { type: 'string' }, |
|
resetPasswordToken: { type: 'string' }, |
|
resetPasswordTokenSentAt: { type: 'string' }, |
|
trialExpiryDate: { type: 'string' }, |
|
roleId: { type: 'string', format: 'uuid' }, |
|
deletedAt: { type: 'string' }, |
|
createdAt: { type: 'string' }, |
|
updatedAt: { type: 'string' }, |
|
}, |
|
}; |
|
|
|
static relationMappings = () => ({ |
|
accessTokens: { |
|
relation: Base.HasManyRelation, |
|
modelClass: AccessToken, |
|
join: { |
|
from: 'users.id', |
|
to: 'access_tokens.user_id', |
|
}, |
|
}, |
|
connections: { |
|
relation: Base.HasManyRelation, |
|
modelClass: Connection, |
|
join: { |
|
from: 'users.id', |
|
to: 'connections.user_id', |
|
}, |
|
}, |
|
flows: { |
|
relation: Base.HasManyRelation, |
|
modelClass: Flow, |
|
join: { |
|
from: 'users.id', |
|
to: 'flows.user_id', |
|
}, |
|
}, |
|
steps: { |
|
relation: Base.ManyToManyRelation, |
|
modelClass: Step, |
|
join: { |
|
from: 'users.id', |
|
through: { |
|
from: 'flows.user_id', |
|
to: 'flows.id', |
|
}, |
|
to: 'steps.flow_id', |
|
}, |
|
}, |
|
executions: { |
|
relation: Base.ManyToManyRelation, |
|
modelClass: Execution, |
|
join: { |
|
from: 'users.id', |
|
through: { |
|
from: 'flows.user_id', |
|
to: 'flows.id', |
|
}, |
|
to: 'executions.flow_id', |
|
}, |
|
}, |
|
usageData: { |
|
relation: Base.HasManyRelation, |
|
modelClass: UsageData, |
|
join: { |
|
from: 'usage_data.user_id', |
|
to: 'users.id', |
|
}, |
|
}, |
|
currentUsageData: { |
|
relation: Base.HasOneRelation, |
|
modelClass: UsageData, |
|
join: { |
|
from: 'usage_data.user_id', |
|
to: 'users.id', |
|
}, |
|
filter(builder) { |
|
builder.orderBy('created_at', 'desc').limit(1).first(); |
|
}, |
|
}, |
|
subscriptions: { |
|
relation: Base.HasManyRelation, |
|
modelClass: Subscription, |
|
join: { |
|
from: 'subscriptions.user_id', |
|
to: 'users.id', |
|
}, |
|
}, |
|
currentSubscription: { |
|
relation: Base.HasOneRelation, |
|
modelClass: Subscription, |
|
join: { |
|
from: 'subscriptions.user_id', |
|
to: 'users.id', |
|
}, |
|
filter(builder) { |
|
builder.orderBy('created_at', 'desc').limit(1).first(); |
|
}, |
|
}, |
|
role: { |
|
relation: Base.HasOneRelation, |
|
modelClass: Role, |
|
join: { |
|
from: 'roles.id', |
|
to: 'users.role_id', |
|
}, |
|
}, |
|
permissions: { |
|
relation: Base.HasManyRelation, |
|
modelClass: Permission, |
|
join: { |
|
from: 'users.role_id', |
|
to: 'permissions.role_id', |
|
}, |
|
}, |
|
identities: { |
|
relation: Base.HasManyRelation, |
|
modelClass: Identity, |
|
join: { |
|
from: 'identities.user_id', |
|
to: 'users.id', |
|
}, |
|
}, |
|
}); |
|
|
|
get authorizedFlows() { |
|
const conditions = this.can('read', 'Flow'); |
|
return conditions.isCreator ? this.$relatedQuery('flows') : Flow.query(); |
|
} |
|
|
|
get authorizedSteps() { |
|
const conditions = this.can('read', 'Flow'); |
|
return conditions.isCreator ? this.$relatedQuery('steps') : Step.query(); |
|
} |
|
|
|
get authorizedConnections() { |
|
const conditions = this.can('read', 'Connection'); |
|
return conditions.isCreator |
|
? this.$relatedQuery('connections') |
|
: Connection.query(); |
|
} |
|
|
|
get authorizedExecutions() { |
|
const conditions = this.can('read', 'Execution'); |
|
return conditions.isCreator |
|
? this.$relatedQuery('executions') |
|
: Execution.query(); |
|
} |
|
|
|
static async authenticate(email, password) { |
|
const user = await User.query().findOne({ |
|
email: email?.toLowerCase() || null, |
|
}); |
|
|
|
if (user && (await user.login(password))) { |
|
const token = await createAuthTokenByUserId(user.id); |
|
return token; |
|
} |
|
} |
|
|
|
login(password) { |
|
return bcrypt.compare(password, this.password); |
|
} |
|
|
|
async generateResetPasswordToken() { |
|
const resetPasswordToken = crypto.randomBytes(64).toString('hex'); |
|
const resetPasswordTokenSentAt = new Date().toISOString(); |
|
|
|
await this.$query().patch({ resetPasswordToken, resetPasswordTokenSentAt }); |
|
} |
|
|
|
async resetPassword(password) { |
|
return await this.$query().patch({ |
|
resetPasswordToken: null, |
|
resetPasswordTokenSentAt: null, |
|
password, |
|
}); |
|
} |
|
|
|
async isResetPasswordTokenValid() { |
|
if (!this.resetPasswordTokenSentAt) { |
|
return false; |
|
} |
|
|
|
const sentAt = new Date(this.resetPasswordTokenSentAt); |
|
const now = new Date(); |
|
const fourHoursInMilliseconds = 1000 * 60 * 60 * 4; |
|
|
|
return now.getTime() - sentAt.getTime() < fourHoursInMilliseconds; |
|
} |
|
|
|
async generateHash() { |
|
if (this.password) { |
|
this.password = await bcrypt.hash(this.password, 10); |
|
} |
|
} |
|
|
|
async startTrialPeriod() { |
|
this.trialExpiryDate = DateTime.now().plus({ days: 30 }).toISODate(); |
|
} |
|
|
|
async isAllowedToRunFlows() { |
|
if (appConfig.isSelfHosted) { |
|
return true; |
|
} |
|
|
|
if (await this.inTrial()) { |
|
return true; |
|
} |
|
|
|
if ((await this.hasActiveSubscription()) && (await this.withinLimits())) { |
|
return true; |
|
} |
|
|
|
return false; |
|
} |
|
|
|
async inTrial() { |
|
if (appConfig.isSelfHosted) { |
|
return false; |
|
} |
|
|
|
if (!this.trialExpiryDate) { |
|
return false; |
|
} |
|
|
|
if (await this.hasActiveSubscription()) { |
|
return false; |
|
} |
|
|
|
const expiryDate = DateTime.fromJSDate(this.trialExpiryDate); |
|
const now = DateTime.now(); |
|
|
|
return now < expiryDate; |
|
} |
|
|
|
async hasActiveSubscription() { |
|
if (!appConfig.isCloud) { |
|
return false; |
|
} |
|
|
|
const subscription = await this.$relatedQuery('currentSubscription'); |
|
|
|
return subscription?.isValid; |
|
} |
|
|
|
async withinLimits() { |
|
const currentSubscription = await this.$relatedQuery('currentSubscription'); |
|
const plan = currentSubscription.plan; |
|
const currentUsageData = await this.$relatedQuery('currentUsageData'); |
|
|
|
return currentUsageData.consumedTaskCount < plan.quota; |
|
} |
|
|
|
async getPlanAndUsage() { |
|
const usageData = await this.$relatedQuery( |
|
'currentUsageData' |
|
).throwIfNotFound(); |
|
|
|
const subscription = await this.$relatedQuery('currentSubscription'); |
|
|
|
const currentPlan = Billing.paddlePlans.find( |
|
(plan) => plan.productId === subscription?.paddlePlanId |
|
); |
|
|
|
const planAndUsage = { |
|
usage: { |
|
task: usageData.consumedTaskCount, |
|
}, |
|
plan: { |
|
id: subscription?.paddlePlanId || null, |
|
name: subscription ? currentPlan.name : 'Free Trial', |
|
limit: currentPlan?.limit || null, |
|
}, |
|
}; |
|
|
|
return planAndUsage; |
|
} |
|
|
|
async getInvoices() { |
|
const subscription = await this.$relatedQuery('currentSubscription'); |
|
|
|
if (!subscription) { |
|
return []; |
|
} |
|
|
|
const invoices = await Billing.paddleClient.getInvoices( |
|
Number(subscription.paddleSubscriptionId) |
|
); |
|
|
|
return invoices; |
|
} |
|
|
|
async getApps(name) { |
|
const connections = await this.authorizedConnections |
|
.clone() |
|
.select('connections.key') |
|
.where({ draft: false }) |
|
.count('connections.id as count') |
|
.groupBy('connections.key'); |
|
|
|
const flows = await this.authorizedFlows |
|
.clone() |
|
.withGraphJoined('steps') |
|
.orderBy('created_at', 'desc'); |
|
|
|
const duplicatedUsedApps = flows |
|
.map((flow) => flow.steps.map((step) => step.appKey)) |
|
.flat() |
|
.filter(Boolean); |
|
|
|
const connectionKeys = connections.map((connection) => connection.key); |
|
const usedApps = [...new Set([...duplicatedUsedApps, ...connectionKeys])]; |
|
|
|
let apps = await App.findAll(name); |
|
|
|
apps = apps |
|
.filter((app) => { |
|
return usedApps.includes(app.key); |
|
}) |
|
.map((app) => { |
|
const connection = connections.find( |
|
(connection) => connection.key === app.key |
|
); |
|
|
|
app.connectionCount = connection?.count || 0; |
|
app.flowCount = 0; |
|
|
|
flows.forEach((flow) => { |
|
const usedFlow = flow.steps.find((step) => step.appKey === app.key); |
|
|
|
if (usedFlow) { |
|
app.flowCount += 1; |
|
} |
|
}); |
|
|
|
return app; |
|
}) |
|
.sort((appA, appB) => appA.name.localeCompare(appB.name)); |
|
|
|
return apps; |
|
} |
|
|
|
static async createAdmin({ email, password, fullName }) { |
|
const adminRole = await Role.findAdmin(); |
|
|
|
const adminUser = await this.query().insert({ |
|
email, |
|
password, |
|
fullName, |
|
roleId: adminRole.id |
|
}); |
|
|
|
await Config.markInstallationCompleted(); |
|
|
|
return adminUser; |
|
} |
|
|
|
async $beforeInsert(queryContext) { |
|
await super.$beforeInsert(queryContext); |
|
|
|
this.email = this.email.toLowerCase(); |
|
await this.generateHash(); |
|
|
|
if (appConfig.isCloud) { |
|
await this.startTrialPeriod(); |
|
} |
|
} |
|
|
|
async $beforeUpdate(opt, queryContext) { |
|
await super.$beforeUpdate(opt, queryContext); |
|
|
|
if (this.email) { |
|
this.email = this.email.toLowerCase(); |
|
} |
|
|
|
await this.generateHash(); |
|
} |
|
|
|
async $afterInsert(queryContext) { |
|
await super.$afterInsert(queryContext); |
|
|
|
if (appConfig.isCloud) { |
|
await this.$relatedQuery('usageData').insert({ |
|
userId: this.id, |
|
consumedTaskCount: 0, |
|
nextResetAt: DateTime.now().plus({ days: 30 }).toISODate(), |
|
}); |
|
} |
|
} |
|
|
|
async $afterFind() { |
|
if (await hasValidLicense()) return this; |
|
|
|
if (Array.isArray(this.permissions)) { |
|
this.permissions = this.permissions.filter((permission) => { |
|
const restrictedSubjects = [ |
|
'App', |
|
'Role', |
|
'SamlAuthProvider', |
|
'Config', |
|
]; |
|
|
|
return !restrictedSubjects.includes(permission.subject); |
|
}); |
|
} |
|
|
|
return this; |
|
} |
|
|
|
get ability() { |
|
return userAbility(this); |
|
} |
|
|
|
can(action, subject) { |
|
const can = this.ability.can(action, subject); |
|
|
|
if (!can) throw new Error('Not authorized!'); |
|
|
|
const relevantRule = this.ability.relevantRuleFor(action, subject); |
|
|
|
const conditions = relevantRule?.conditions || []; |
|
const conditionMap = Object.fromEntries( |
|
conditions.map((condition) => [condition, true]) |
|
); |
|
|
|
return conditionMap; |
|
} |
|
|
|
cannot(action, subject) { |
|
const cannot = this.ability.cannot(action, subject); |
|
|
|
if (cannot) throw new Error('Not authorized!'); |
|
|
|
return cannot; |
|
} |
|
} |
|
|
|
export default User; |
|
|