667 lines
16 KiB
JavaScript
667 lines
16 KiB
JavaScript
import bcrypt from 'bcrypt';
|
|
import { DateTime, Duration } from 'luxon';
|
|
import crypto from 'node:crypto';
|
|
import { ValidationError } from 'objection';
|
|
|
|
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 ExecutionStep from './execution-step.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';
|
|
import NotAuthorizedError from '../errors/not-authorized.js';
|
|
|
|
import deleteUserQueue from '../queues/delete-user.ee.js';
|
|
import flowQueue from '../queues/flow.js';
|
|
import emailQueue from '../queues/email.js';
|
|
import {
|
|
REMOVE_AFTER_30_DAYS_OR_150_JOBS,
|
|
REMOVE_AFTER_7_DAYS_OR_50_JOBS,
|
|
} from '../helpers/remove-job-configuration.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', minLength: 6 },
|
|
status: {
|
|
type: 'string',
|
|
enum: ['active', 'invited'],
|
|
default: 'active',
|
|
},
|
|
resetPasswordToken: { type: ['string', 'null'] },
|
|
resetPasswordTokenSentAt: {
|
|
type: ['string', 'null'],
|
|
format: 'date-time',
|
|
},
|
|
invitationToken: { type: ['string', 'null'] },
|
|
invitationTokenSentAt: {
|
|
type: ['string', 'null'],
|
|
format: 'date-time',
|
|
},
|
|
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',
|
|
},
|
|
},
|
|
});
|
|
|
|
static get virtualAttributes() {
|
|
return ['acceptInvitationUrl'];
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
get acceptInvitationUrl() {
|
|
return `${appConfig.webAppUrl}/accept-invitation?token=${this.invitationToken}`;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
async login(password) {
|
|
return await 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 generateInvitationToken() {
|
|
const invitationToken = crypto.randomBytes(64).toString('hex');
|
|
const invitationTokenSentAt = new Date().toISOString();
|
|
|
|
await this.$query().patchAndFetch({
|
|
invitationToken,
|
|
invitationTokenSentAt,
|
|
});
|
|
}
|
|
|
|
async resetPassword(password) {
|
|
return await this.$query().patch({
|
|
resetPasswordToken: null,
|
|
resetPasswordTokenSentAt: null,
|
|
password,
|
|
});
|
|
}
|
|
|
|
async acceptInvitation(password) {
|
|
return await this.$query().patch({
|
|
invitationToken: null,
|
|
invitationTokenSentAt: null,
|
|
status: 'active',
|
|
password,
|
|
});
|
|
}
|
|
|
|
async updatePassword({ currentPassword, password }) {
|
|
if (await User.authenticate(this.email, currentPassword)) {
|
|
const user = await this.$query().patchAndFetch({
|
|
password,
|
|
});
|
|
|
|
return user;
|
|
}
|
|
|
|
throw new ValidationError({
|
|
data: {
|
|
currentPassword: [
|
|
{
|
|
message: 'is incorrect.',
|
|
},
|
|
],
|
|
},
|
|
type: 'ValidationError',
|
|
});
|
|
}
|
|
|
|
async softRemove() {
|
|
await this.softRemoveAssociations();
|
|
await this.$query().delete();
|
|
|
|
const jobName = `Delete user - ${this.id}`;
|
|
const jobPayload = { id: this.id };
|
|
const millisecondsFor30Days = Duration.fromObject({ days: 30 }).toMillis();
|
|
const jobOptions = {
|
|
delay: millisecondsFor30Days,
|
|
};
|
|
|
|
await deleteUserQueue.add(jobName, jobPayload, jobOptions);
|
|
}
|
|
|
|
async softRemoveAssociations() {
|
|
const flows = await this.$relatedQuery('flows').where({
|
|
active: true,
|
|
});
|
|
|
|
const repeatableJobs = await flowQueue.getRepeatableJobs();
|
|
|
|
for (const flow of flows) {
|
|
const job = repeatableJobs.find((job) => job.id === flow.id);
|
|
|
|
if (job) {
|
|
await flowQueue.removeRepeatableByKey(job.key);
|
|
}
|
|
}
|
|
|
|
const executionIds = (
|
|
await this.$relatedQuery('executions').select('executions.id')
|
|
).map((execution) => execution.id);
|
|
const flowIds = flows.map((flow) => flow.id);
|
|
|
|
await this.$relatedQuery('accessTokens').delete();
|
|
await ExecutionStep.query().delete().whereIn('execution_id', executionIds);
|
|
await this.$relatedQuery('executions').delete();
|
|
await this.$relatedQuery('steps').delete();
|
|
await Flow.query().whereIn('id', flowIds).delete();
|
|
await this.$relatedQuery('connections').delete();
|
|
await this.$relatedQuery('identities').delete();
|
|
|
|
if (appConfig.isCloud) {
|
|
await this.$relatedQuery('subscriptions').delete();
|
|
await this.$relatedQuery('usageData').delete();
|
|
}
|
|
}
|
|
|
|
async sendResetPasswordEmail() {
|
|
await this.generateResetPasswordToken();
|
|
|
|
const jobName = `Reset Password Email - ${this.id}`;
|
|
|
|
const jobPayload = {
|
|
email: this.email,
|
|
subject: 'Reset Password',
|
|
template: 'reset-password-instructions.ee',
|
|
params: {
|
|
token: this.resetPasswordToken,
|
|
webAppUrl: appConfig.webAppUrl,
|
|
fullName: this.fullName,
|
|
},
|
|
};
|
|
|
|
const jobOptions = {
|
|
removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS,
|
|
removeOnFail: REMOVE_AFTER_30_DAYS_OR_150_JOBS,
|
|
};
|
|
|
|
await emailQueue.add(jobName, jobPayload, jobOptions);
|
|
}
|
|
|
|
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 sendInvitationEmail() {
|
|
await this.generateInvitationToken();
|
|
|
|
const jobName = `Invitation Email - ${this.id}`;
|
|
|
|
const jobPayload = {
|
|
email: this.email,
|
|
subject: 'You are invited!',
|
|
template: 'invitation-instructions',
|
|
params: {
|
|
fullName: this.fullName,
|
|
acceptInvitationUrl: this.acceptInvitationUrl,
|
|
},
|
|
};
|
|
|
|
const jobOptions = {
|
|
removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS,
|
|
removeOnFail: REMOVE_AFTER_30_DAYS_OR_150_JOBS,
|
|
};
|
|
|
|
await emailQueue.add(jobName, jobPayload, jobOptions);
|
|
}
|
|
|
|
isInvitationTokenValid() {
|
|
if (!this.invitationTokenSentAt) {
|
|
return false;
|
|
}
|
|
|
|
const sentAt = new Date(this.invitationTokenSentAt);
|
|
const now = new Date();
|
|
const seventyTwoHoursInMilliseconds = 1000 * 60 * 60 * 72;
|
|
|
|
return now.getTime() - sentAt.getTime() < seventyTwoHoursInMilliseconds;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
static async registerUser(userData) {
|
|
const { fullName, email, password } = userData;
|
|
|
|
const role = await Role.query().findOne({ name: 'User' }).throwIfNotFound();
|
|
|
|
const user = await User.query().insertAndFetch({
|
|
fullName,
|
|
email,
|
|
password,
|
|
roleId: role.id,
|
|
});
|
|
|
|
return user;
|
|
}
|
|
|
|
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 NotAuthorizedError('The user is 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 NotAuthorizedError();
|
|
|
|
return cannot;
|
|
}
|
|
}
|
|
|
|
export default User;
|