Files
huly-platform/server/account/src/operations.ts
T

2282 lines
70 KiB
TypeScript

//
// Copyright © 2022-2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { Analytics } from '@hcengineering/analytics'
import {
type AccountInfo,
AccountRole,
type AccountUuid,
type Branding,
buildSocialIdString,
concatLink,
isActiveMode,
isDeletingMode,
isWorkspaceCreating,
loginSocialTypes,
type MeasureContext,
type Person,
type PersonId,
type PersonUuid,
SocialIdType,
systemAccountUuid,
readOnlyGuestAccountUuid,
type WorkspaceMemberInfo,
type WorkspaceUuid
} from '@hcengineering/core'
import platform, { getMetadata, PlatformError, Severity, Status, translate } from '@hcengineering/platform'
import { decodeTokenVerbose, generateToken } from '@hcengineering/server-token'
import { isAdminEmail } from './admin'
import { accountPlugin } from './plugin'
import { type AccountServiceMethods, getServiceMethods } from './serviceOperations'
import {
AccountEventType,
type AccountDB,
type AccountMethodHandler,
type LoginInfo,
type LoginInfoWithWorkspaces,
type Mailbox,
type MailboxOptions,
type Meta,
type OtpInfo,
type RegionInfo,
type SocialId,
type WorkspaceInfoWithStatus,
type WorkspaceInviteInfo,
type WorkspaceLoginInfo
} from './types'
import {
addSocialIdBase,
checkInvite,
cleanEmail,
confirmEmail,
confirmHulyIds,
createAccount,
createWorkspaceRecord,
doJoinByInvite,
EndpointKind,
generatePassword,
getAccount,
getEmailSocialId,
getEndpoint,
getEndpointInfo,
getFrontUrl,
getInviteEmail,
getMailUrl,
getPersonName,
getRegions,
getRolePower,
getWorkspaceById,
getWorkspaceEndpoint,
getWorkspaceInfoWithStatusById,
getWorkspaceInvite,
getWorkspaceRole,
getWorkspaceRoles,
GUEST_ACCOUNT,
isEmail,
isOtpValid,
normalizeValue,
doReleaseSocialId,
selectWorkspace,
sendEmail,
sendEmailConfirmation,
sendOtp,
setPassword,
setTimezoneIfNotDefined,
signUpByEmail,
updateWorkspaceRole,
verifyAllowedRole,
verifyAllowedServices,
verifyPassword,
wrap,
updateAllowReadOnlyGuests,
updateAllowGuestSignUp,
getWorkspaceByDataId,
assignableRoles,
getWorkspacesInfoWithStatusByIds,
doMergePersons,
getWorkspaceJoinInfo
} from './utils'
// Note: it is IMPORTANT to always destructure params passed here to avoid sending extra params
// to the database layer when searching/inserting as they may contain SQL injection
// !!! NEVER PASS "params" DIRECTLY in any DB functions !!!
const workspaceLimitPerUser =
process.env.WORKSPACE_LIMIT_PER_USER != null ? parseInt(process.env.WORKSPACE_LIMIT_PER_USER) : 10
/* =================================== */
/* ============OPERATIONS============= */
/* =================================== */
/**
* Given an email and password, logs the user in and returns the account information and token.
*/
export async function loginAsGuest (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string
): Promise<LoginInfo> {
const guestPerson = await db.person.findOne({ uuid: readOnlyGuestAccountUuid as PersonUuid })
if (guestPerson == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {}))
}
return {
account: guestPerson.uuid as AccountUuid,
token: generateToken(guestPerson.uuid, undefined)
}
}
/**
* Given an email and password, logs the user in and returns the account information and token.
*/
export async function login (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: {
email: string
password: string
}
): Promise<LoginInfo> {
const { email, password } = params
if (email == null || password == null || email === '' || password === '') {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const normalizedEmail = cleanEmail(email)
try {
const emailSocialId = await getEmailSocialId(db, normalizedEmail)
if (emailSocialId == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {}))
}
const existingAccount = await db.account.findOne({ uuid: emailSocialId.personUuid as AccountUuid })
if (existingAccount == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {}))
}
const person = await db.person.findOne({ uuid: emailSocialId.personUuid })
if (person == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.InternalServerError, {}))
}
if (!verifyPassword(password, existingAccount.hash, existingAccount.salt)) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {}))
}
const isConfirmed = emailSocialId.verifiedOn != null
const extraToken: Record<string, string> = isAdminEmail(normalizedEmail) ? { admin: 'true' } : {}
ctx.info('Login succeeded', { email, normalizedEmail, isConfirmed, emailSocialId, ...extraToken })
return {
account: existingAccount.uuid,
token: isConfirmed ? generateToken(existingAccount.uuid, undefined, extraToken) : undefined,
name: getPersonName(person),
socialId: emailSocialId._id
}
} catch (err: any) {
Analytics.handleError(err)
ctx.error('Login failed', { email, normalizedEmail, err })
throw err
}
}
/**
* Given an email sends an OTP code to the existing user and returns the OTP information.
*/
export async function loginOtp (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: { email: string }
): Promise<OtpInfo> {
const { email } = params
if (email == null || email === '') {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
// Note: can support OTP based on any other social logins later
const normalizedEmail = cleanEmail(email)
const emailSocialId = await getEmailSocialId(db, normalizedEmail)
if (emailSocialId == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {}))
}
const account = await getAccount(db, emailSocialId.personUuid as AccountUuid)
if (account == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {}))
}
return await sendOtp(ctx, db, branding, emailSocialId)
}
/**
* Given an email, password, first name, and last name, creates a new account and sends a confirmation email.
* The email confirmation is not required if the email service is not configured.
*
* ---------DEPRECATED. Only to be used for dev setups without mail service. Use signUpOtp instead.
*/
export async function signUp (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: {
email: string
password: string
firstName: string
lastName?: string
},
meta?: Meta
): Promise<LoginInfo> {
const { email, password, firstName, lastName } = params
if (email == null || password == null || firstName == null || email === '' || password === '' || firstName === '') {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const { account, socialId } = await signUpByEmail(ctx, db, branding, email, password, firstName, lastName ?? '')
const person = await db.person.findOne({ uuid: account })
if (person == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.InternalServerError, {}))
}
const mailURL = getMetadata(accountPlugin.metadata.MAIL_URL)
const forceConfirmation = mailURL !== undefined && mailURL !== ''
if (forceConfirmation) {
const normalizedEmail = cleanEmail(email)
await sendEmailConfirmation(ctx, branding, account, normalizedEmail)
} else {
ctx.warn('Please provide MAIL_URL to enable sign up email confirmations.')
await confirmEmail(ctx, db, account, email)
await confirmHulyIds(ctx, db, account)
}
void setTimezoneIfNotDefined(ctx, db, account, null, meta)
return {
account,
name: getPersonName(person),
socialId,
token: !forceConfirmation ? generateToken(account) : undefined
}
}
export async function signUpOtp (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: {
email: string
firstName: string
lastName?: string
}
): Promise<OtpInfo> {
const { email, firstName, lastName } = params
if (email == null || firstName == null || email === '' || firstName === '') {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
// Note: can support OTP based on any other social logins later
const normalizedEmail = cleanEmail(email)
let emailSocialId = await getEmailSocialId(db, normalizedEmail)
let personUuid: PersonUuid
if (emailSocialId !== null) {
const existingAccount = await db.account.findOne({ uuid: emailSocialId.personUuid as AccountUuid })
if (existingAccount !== null) {
ctx.error('An account with the provided email already exists', { email })
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountAlreadyExists, {}))
}
await db.person.update({ uuid: emailSocialId.personUuid }, { firstName, lastName: lastName ?? '' })
personUuid = emailSocialId.personUuid
} else {
// There's no person linked to this email, so we need to create a new one
personUuid = await db.person.insertOne({ firstName, lastName: lastName ?? '' })
const newSocialId = { type: SocialIdType.EMAIL, value: normalizedEmail, personUuid }
const emailSocialIdId = await db.socialId.insertOne(newSocialId)
emailSocialId = { ...newSocialId, _id: emailSocialIdId, key: buildSocialIdString(newSocialId) }
}
return await sendOtp(ctx, db, branding, emailSocialId)
}
/**
* Validates email OTP for login/sign up/new social id
*/
export async function validateOtp (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: {
email: string
code: string
password?: string
action?: 'verify'
}
): Promise<LoginInfo> {
const { email, code, password, action } = params
if (email == null || code == null || email === '' || code === '') {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
// Note: can support OTP based on any other social logins later
const normalizedEmail = cleanEmail(email)
try {
let emailSocialId = await getEmailSocialId(db, normalizedEmail)
if (emailSocialId == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: email }))
}
const isValid = await isOtpValid(db, emailSocialId._id, code)
if (!isValid) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.InvalidOtp, {}))
}
let callerAccountUuid: AccountUuid | null = null
if (action === 'verify') {
callerAccountUuid = decodeTokenVerbose(ctx, token).account
const callerAccount = await db.account.findOne({ uuid: callerAccountUuid })
if (callerAccount == null) {
throw new PlatformError(
new Status(Severity.ERROR, platform.status.AccountNotFound, { account: callerAccountUuid })
)
}
}
await db.otp.deleteMany({ socialId: emailSocialId._id })
const targetAccount = await db.account.findOne({ uuid: emailSocialId.personUuid as AccountUuid })
if (action !== 'verify') {
// login/sign up
if (emailSocialId.verifiedOn == null) {
await db.socialId.update({ _id: emailSocialId._id }, { verifiedOn: Date.now() })
}
if (targetAccount == null) {
// This is a signup
await createAccount(db, emailSocialId.personUuid, true)
if (password != null) {
await setPassword(ctx, db, branding, emailSocialId.personUuid as AccountUuid, password)
}
ctx.info('OTP signup success', emailSocialId)
} else {
await confirmHulyIds(ctx, db, targetAccount.uuid)
ctx.info('OTP login/verification success', emailSocialId)
}
} else {
if (callerAccountUuid == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {}))
}
if (targetAccount == null) {
// only person exists means there's no verified social id associated with it -> merge it to the current account
// doMergePersons will fail if there's a verified social id
await doMergePersons(db, callerAccountUuid, emailSocialId.personUuid)
// what happens to local persons referencing this person in various workspaces?
// there can be some persons but no Employees because there's no account
// we know where the person is migrated to so can update later as needed
if (emailSocialId.verifiedOn == null) {
await db.socialId.update({ _id: emailSocialId._id }, { verifiedOn: Date.now() })
} else {
// Normally, it should not be the case
ctx.error("Verifying new social id belonging to person w/o account but it's already verified", {
emailSocialId,
callerAccountUuid
})
}
} else {
if (callerAccountUuid === targetAccount.uuid) {
if (emailSocialId.verifiedOn == null) {
await db.socialId.update({ _id: emailSocialId._id }, { verifiedOn: Date.now() })
}
} else {
if (emailSocialId.verifiedOn == null) {
// Move the target social id to current account, we can easily do this because it was not verified
await db.socialId.update(
{ _id: emailSocialId._id },
{ personUuid: callerAccountUuid, verifiedOn: Date.now() }
)
} else {
// Throw for now. Should probably be another workflow to merge accounts.
// Alternatively, we can allow the same workflow as with not verfied here just to move
// the social id to the current account, but need to add extra checks like there's at least one more
// login method for the target account
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountAlreadyExists, {}))
}
}
}
emailSocialId = await db.socialId.findOne({ _id: emailSocialId._id })
if (emailSocialId == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.InternalServerError, {}))
}
}
const person = await db.person.findOne({ uuid: emailSocialId.personUuid })
if (person == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.InternalServerError, {}))
}
const extraToken: Record<string, string> = isAdminEmail(normalizedEmail) ? { admin: 'true' } : {}
return {
account: emailSocialId.personUuid as AccountUuid,
name: getPersonName(person),
socialId: emailSocialId._id,
token: generateToken(emailSocialId.personUuid, undefined, extraToken)
}
} catch (err: any) {
Analytics.handleError(err)
ctx.error(action === 'verify' ? 'OTP verification error' : 'OTP login/sign up error', { email, err })
throw err
}
}
export async function createWorkspace (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: {
workspaceName: string
region?: string
}
): Promise<WorkspaceLoginInfo> {
const { workspaceName, region } = params
if (workspaceName == null || workspaceName.length === 0) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const { account } = decodeTokenVerbose(ctx, token)
checkRateLimit(account, workspaceName)
ctx.info('Creating workspace record', { workspaceName, account, region })
// Any confirmed social ID will do
const socialId = (await getSocialIds(ctx, db, branding, token, { confirmed: true, includeDeleted: false }))[0]
if (socialId == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotConfirmed, {}))
}
const person = await db.person.findOne({ uuid: socialId.personUuid })
if (person == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.InternalServerError, {}))
}
const accountObj = await db.account.findOne({ uuid: account })
if (accountObj == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.InternalServerError, {}))
}
// Get a list of created workspaces
const created = (await db.workspace.find({ createdBy: socialId.personUuid })).length
if (created >= (accountObj.maxWorkspaces ?? workspaceLimitPerUser)) {
ctx.warn('created-by-limit', { person: socialId.key, workspace: workspaceName })
throw new PlatformError(
new Status(Severity.ERROR, platform.status.WorkspaceLimitReached, { workspace: workspaceName })
)
}
const { workspaceUuid, workspaceUrl } = await createWorkspaceRecord(ctx, db, branding, workspaceName, account, region)
await db.assignWorkspace(account, workspaceUuid, AccountRole.Owner)
ctx.info('Creating workspace record done', { workspaceName, region, account: socialId.personUuid })
return {
account,
socialId: socialId._id,
name: getPersonName(person),
token: generateToken(account, workspaceUuid),
endpoint: getEndpoint(workspaceUuid, region, EndpointKind.External),
workspace: workspaceUuid,
workspaceUrl,
role: AccountRole.Owner
}
}
export async function createInvite (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: {
exp: number
emailMask?: string
email?: string
limit: number
role: AccountRole
autoJoin?: boolean
}
): Promise<string> {
const { exp, emailMask, email, limit, role, autoJoin } = params
if (role == null || !assignableRoles.includes(role)) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const { account, workspace: workspaceUuid, extra } = decodeTokenVerbose(ctx, token)
const currentAccount = await db.account.findOne({ uuid: account })
if (currentAccount == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account }))
}
const workspace = await db.workspace.findOne({ uuid: workspaceUuid })
if (workspace == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid }))
}
const callerRole = await db.getWorkspaceRole(account, workspace.uuid)
verifyAllowedRole(callerRole, role, extra)
if (autoJoin === true) {
verifyAllowedServices(['schedule'], extra)
}
ctx.info('Creating invite', { workspace, workspaceName: workspace.name, email, emailMask, limit, autoJoin })
return await db.invite.insertOne({
workspaceUuid,
expiresOn: exp < 0 ? -1 : Date.now() + exp,
email,
emailPattern: emailMask,
remainingUses: limit,
role,
autoJoin
})
}
// TODO: Temporary solution to prevent spam using sendInvite
const invitesSend = new Map<
string,
{
lastSend: number
totalSend: number
}
>()
export async function sendInvite (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: {
email: string
role: AccountRole
expHours?: number
}
): Promise<void> {
const { email, role, expHours } = params
if (email == null || email === '' || role == null || !assignableRoles.includes(role)) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const { account, workspace: workspaceUuid, extra } = decodeTokenVerbose(ctx, token)
const currentAccount = await db.account.findOne({ uuid: account })
if (currentAccount == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account }))
}
const workspace = await db.workspace.findOne({ uuid: workspaceUuid })
if (workspace == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid }))
}
const callerRole = await db.getWorkspaceRole(account, workspace.uuid)
verifyAllowedRole(callerRole, role, extra)
const inviteLink = await createInviteLink(ctx, db, branding, token, params)
const inviteEmail = await getInviteEmail(branding, email, inviteLink, workspace, expHours ?? 48, false)
await sendEmail(inviteEmail, ctx)
ctx.info('Invite has been sent', { to: inviteEmail.to, workspaceUuid: workspace.uuid, workspaceName: workspace.name })
}
export async function createInviteLink (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: {
email: string
role: AccountRole
autoJoin?: boolean
firstName?: string
lastName?: string
navigateUrl?: string
expHours?: number
}
): Promise<string> {
const { email, role, autoJoin, firstName, lastName, navigateUrl, expHours } = params
if (email == null || email === '' || role == null || !assignableRoles.includes(role)) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const { account, workspace: workspaceUuid, extra } = decodeTokenVerbose(ctx, token)
const currentAccount = await db.account.findOne({ uuid: account })
if (currentAccount == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account }))
}
const workspace = await db.workspace.findOne({ uuid: workspaceUuid })
if (workspace == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid }))
}
const callerRole = await db.getWorkspaceRole(account, workspace.uuid)
verifyAllowedRole(callerRole, role, extra)
if (autoJoin === true) {
verifyAllowedServices(['schedule'], extra)
if (firstName == null || firstName === '') {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
}
const normalizedEmail = cleanEmail(email)
const expiringInHrs = expHours ?? 48
const exp = expiringInHrs * 60 * 60 * 1000
const inviteId = await createInvite(ctx, db, branding, token, {
exp,
email: normalizedEmail,
limit: 1,
role,
autoJoin
})
let path = `/login/join?inviteId=${inviteId}`
if (autoJoin === true) {
path += `&autoJoin&firstName=${encodeURIComponent((firstName ?? '').trim())}`
if (lastName != null) {
path += `&lastName=${encodeURIComponent(lastName.trim())}`
}
}
if (navigateUrl != null) {
path += `&navigateUrl=${encodeURIComponent(navigateUrl.trim())}`
}
const front = getFrontUrl(branding)
const link = concatLink(front, path)
ctx.info(`Created invite link: ${link}`)
return link
}
function checkRateLimit (email: string, workspaceName: string): void {
const now = Date.now()
const lastInvites = invitesSend.get(email)
if (lastInvites !== undefined) {
lastInvites.totalSend++
lastInvites.lastSend = now
if (lastInvites.totalSend > 5 && now - lastInvites.lastSend < 60 * 1000) {
// Less 60 seconds between invites
throw new PlatformError(
new Status(Severity.ERROR, platform.status.WorkspaceRateLimit, { workspace: workspaceName })
)
}
invitesSend.delete(email)
} else {
invitesSend.set(email, {
lastSend: now,
totalSend: 1
})
}
// We need to cleanup map
for (const [k, vv] of invitesSend.entries()) {
if (vv.lastSend < now - 60 * 1000) {
invitesSend.delete(k)
}
}
}
export async function resendInvite (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: {
email: string
role: AccountRole
}
): Promise<void> {
const { email, role } = params
if (email == null || email === '' || role == null || !assignableRoles.includes(role)) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const { account, workspace: workspaceUuid, extra } = decodeTokenVerbose(ctx, token)
const currentAccount = await db.account.findOne({ uuid: account })
if (currentAccount == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account }))
}
const workspace = await db.workspace.findOne({ uuid: workspaceUuid })
if (workspace == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid }))
}
checkRateLimit(account, workspaceUuid)
const callerRole = await db.getWorkspaceRole(account, workspace.uuid)
verifyAllowedRole(callerRole, role, extra)
const expHours = 48
const newExp = Date.now() + expHours * 60 * 60 * 1000
const invite = await db.invite.findOne({ workspaceUuid, email })
let inviteId: string
if (invite != null) {
inviteId = invite.id
await db.invite.update({ id: invite.id }, { expiresOn: newExp, remainingUses: 1, role })
} else {
inviteId = await createInvite(ctx, db, branding, token, { exp: newExp, email, limit: 1, role })
}
const front = getFrontUrl(branding)
const link = concatLink(front, `/login/join?inviteId=${inviteId}`)
const inviteEmail = await getInviteEmail(branding, email, link, workspace, expHours, true)
await sendEmail(inviteEmail, ctx)
ctx.info('Invite has been resent', {
to: inviteEmail.to,
workspaceUuid: workspace.uuid,
workspaceName: workspace.name
})
}
/**
* Given an invite and sign in information, assigns the user to the workspace in a given role.
* If already a member, updates the role if necessary.
* Returns the workspace login information.
*/
export async function join (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
_token: string,
params: {
email: string
password: string
inviteId: string
workspaceUrl: string
},
meta?: Meta
): Promise<WorkspaceLoginInfo | LoginInfo> {
const { email, password, inviteId, workspaceUrl } = params
if (password == null || password === '') {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const workspaceJoinInfo = await getWorkspaceJoinInfo(ctx, db, email, inviteId, workspaceUrl)
ctx.info('Joining a workspace using invite', {
email,
normalizedEmail: workspaceJoinInfo.email,
...workspaceJoinInfo.invite
})
const { token, account } = await login(ctx, db, branding, _token, { email: workspaceJoinInfo.email, password })
if (token == null) {
return {
account
}
}
return await doJoinByInvite(ctx, db, branding, token, account, workspaceJoinInfo.workspace, workspaceJoinInfo.invite)
}
/**
* Given an invite and a token, checks if the user has already joined the workspace and updates the role if necessary.
* Returns the workspace login information if the user has already joined. Otherwise, throws an error.
*/
export async function checkJoin (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: { inviteId: string }
): Promise<WorkspaceLoginInfo> {
const { inviteId } = params
if (inviteId == null || inviteId === '') {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const invite = await getWorkspaceInvite(db, inviteId)
if (invite == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
const { account: accountUuid } = decodeTokenVerbose(ctx, token)
const emailSocialId = await db.socialId.findOne({
type: SocialIdType.EMAIL,
personUuid: accountUuid,
verifiedOn: { $gt: 0 }
})
const email = emailSocialId?.value ?? ''
const workspaceUuid = await checkInvite(ctx, invite, email)
const workspace = await getWorkspaceById(db, workspaceUuid)
if (workspace === null) {
ctx.error('Workspace not found in checkJoin', { workspaceUuid, email, inviteId })
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid }))
}
const wsLoginInfo = await selectWorkspace(ctx, db, branding, token, { workspaceUrl: workspace.url, kind: 'external' })
if (getRolePower(wsLoginInfo.role) < getRolePower(invite.role)) {
await db.updateWorkspaceRole(accountUuid, workspaceUuid, invite.role)
}
return {
...wsLoginInfo,
role: invite.role
}
}
export async function checkAutoJoin (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: { inviteId: string, firstName?: string, lastName?: string }
): Promise<WorkspaceLoginInfo | WorkspaceInviteInfo> {
const { inviteId, firstName, lastName } = params
if (inviteId == null || inviteId === '') {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const invite = await getWorkspaceInvite(db, inviteId)
if (invite == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
if (invite.autoJoin !== true) {
ctx.error('Not an auto-join invite', invite)
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
if (invite.role !== AccountRole.Guest) {
ctx.error('Auto-join not for guest role is forbidden', invite)
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
const normalizedEmail = invite.email != null ? cleanEmail(invite.email) : ''
const workspaceUuid = invite.workspaceUuid
const workspace = await getWorkspaceById(db, workspaceUuid)
if (workspace === null) {
ctx.error('Workspace not found in auto-joining workflow', { workspaceUuid, email: normalizedEmail, inviteId })
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid }))
}
if (normalizedEmail == null || normalizedEmail === '') {
ctx.error('Malformed auto-join invite', invite)
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const emailSocialId = await db.socialId.findOne({
type: SocialIdType.EMAIL,
value: normalizedEmail
})
// If it's an existing account we should check for saved token or ask for login to prevent accidental access through shared link
if (emailSocialId != null) {
const targetAccount = await getAccount(db, emailSocialId.personUuid as AccountUuid)
if (targetAccount != null) {
if (targetAccount.automatic == null || !targetAccount.automatic) {
if (token == null) {
// Login required
const person = await db.person.findOne({ uuid: targetAccount.uuid })
return {
workspace: workspace.uuid,
name: person == null ? '' : getPersonName(person),
email: normalizedEmail
}
}
const { account: callerAccount } = decodeTokenVerbose(ctx, token)
if (callerAccount !== targetAccount.uuid) {
// Login with target email required
const person = await db.person.findOne({ uuid: targetAccount.uuid })
return {
workspace: workspace.uuid,
name: person == null ? '' : getPersonName(person),
email: normalizedEmail
}
}
}
const targetRole = await getWorkspaceRole(db, targetAccount.uuid, workspace.uuid)
if (targetRole == null) {
await db.assignWorkspace(targetAccount.uuid, workspace.uuid, invite.role)
} else if (getRolePower(targetRole) < getRolePower(invite.role)) {
await db.updateWorkspaceRole(targetAccount.uuid, workspace.uuid, invite.role)
}
if (token === undefined || token === null) {
token = generateToken(targetAccount.uuid)
}
return await selectWorkspace(ctx, db, branding, token, { workspaceUrl: workspace.url, kind: 'external' })
}
}
// No account yet, create a new one automatically
if (firstName == null || firstName === '') {
ctx.error('First name is required for auto-join', { firstName })
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const { account } = await signUpByEmail(
ctx,
db,
branding,
normalizedEmail,
null,
firstName,
lastName ?? '',
true,
true
)
return await doJoinByInvite(ctx, db, branding, generateToken(account, workspaceUuid), account, workspace, invite)
}
/**
* Given an invite and sign up information, creates an account and assigns it to the workspace.
*/
export async function signUpJoin (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: {
email: string
password: string
first: string
last?: string
inviteId: string
workspaceUrl: string
},
meta?: Meta
): Promise<WorkspaceLoginInfo> {
const { email, password, first, last, inviteId, workspaceUrl } = params
if (password == null || password === '' || first == null || first === '') {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const workspaceJoinInfo = await getWorkspaceJoinInfo(ctx, db, email, inviteId, workspaceUrl)
ctx.info('Signing up and joining a workspace using invite', {
email,
normalizedEmail: workspaceJoinInfo.email,
first,
last,
inviteId
})
const { account } = await signUpByEmail(ctx, db, branding, email, password, first, last ?? '', true)
void setTimezoneIfNotDefined(ctx, db, account, null, meta)
return await doJoinByInvite(
ctx,
db,
branding,
generateToken(account, workspaceJoinInfo.workspace?.uuid),
account,
workspaceJoinInfo.workspace,
workspaceJoinInfo.invite
)
}
export async function confirm (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string
): Promise<LoginInfo> {
const { account, extra } = decodeTokenVerbose(ctx, token)
const email = extra?.confirmEmail
if (email === undefined) {
ctx.error('Email not provided for confirmation', { account, extra })
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
const socialId = await confirmEmail(ctx, db, account, email)
await confirmHulyIds(ctx, db, account)
const person = await db.person.findOne({ uuid: account })
if (person == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.PersonNotFound, { person: account }))
}
const result = {
account,
name: getPersonName(person),
socialId,
token: generateToken(account)
}
ctx.info('Email confirmed', { account, email })
return result
}
export async function changePassword (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: {
oldPassword: string
newPassword: string
}
): Promise<void> {
const { oldPassword, newPassword } = params
if (oldPassword == null || oldPassword === '' || newPassword == null || newPassword === '') {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const { account: accountUuid } = decodeTokenVerbose(ctx, token)
ctx.info('Changing password', { accountUuid })
const account = await getAccount(db, accountUuid)
if (account == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: accountUuid }))
}
if (!verifyPassword(oldPassword, account.hash, account.salt)) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
await setPassword(ctx, db, branding, accountUuid, newPassword)
ctx.info('Password changed', { accountUuid })
}
export async function requestPasswordReset (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
_token: string,
params: { email: string }
): Promise<void> {
const { email } = params
if (email == null || email === '') {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const normalizedEmail = cleanEmail(email)
ctx.info('Requesting password reset', { email, normalizedEmail })
const emailSocialId = await getEmailSocialId(db, normalizedEmail)
if (emailSocialId == null) {
ctx.error('Email social id not found', { email, normalizedEmail })
throw new PlatformError(
new Status(Severity.ERROR, platform.status.SocialIdNotFound, { value: email, type: SocialIdType.EMAIL })
)
}
const account = await getAccount(db, emailSocialId.personUuid as AccountUuid)
if (account == null) {
ctx.info('Account not found', { email, normalizedEmail })
throw new PlatformError(
new Status(Severity.ERROR, platform.status.AccountNotFound, { account: emailSocialId.personUuid })
)
}
const { mailURL, mailAuth } = getMailUrl()
const front = getFrontUrl(branding)
const token = generateToken(account.uuid, undefined, {
restoreEmail: normalizedEmail
})
const link = concatLink(front, `/login/recovery?id=${token}`)
const lang = branding?.language
const text = await translate(accountPlugin.string.RecoveryText, { link }, lang)
const html = await translate(accountPlugin.string.RecoveryHTML, { link }, lang)
const subject = await translate(accountPlugin.string.RecoverySubject, {}, lang)
const response = await fetch(concatLink(mailURL, '/send'), {
method: 'post',
headers: {
'Content-Type': 'application/json',
...(mailAuth != null ? { Authorization: `Bearer ${mailAuth}` } : {})
},
body: JSON.stringify({
text,
html,
subject,
to: normalizedEmail
})
})
if (response.ok) {
ctx.info('Password reset email sent', { email, normalizedEmail, account: account.uuid })
} else {
ctx.error(`Failed to send reset password email: ${response.statusText}`, {
email,
normalizedEmail,
account: account.uuid
})
}
}
export async function restorePassword (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: { password: string }
): Promise<LoginInfo> {
const { password } = params
if (password == null || password === '') {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const { account, extra } = decodeTokenVerbose(ctx, token)
ctx.info('Restoring password', { account, extra })
const email = extra?.restoreEmail
if (email === undefined) {
ctx.error('Email not provided for restoration', { account, extra })
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
const emailSocialId = await getEmailSocialId(db, email)
if (emailSocialId == null) {
ctx.error('Email social id not found', { email })
throw new PlatformError(
new Status(Severity.ERROR, platform.status.SocialIdNotFound, { value: email, type: SocialIdType.EMAIL })
)
}
await setPassword(ctx, db, branding, account, password)
if (emailSocialId.verifiedOn == null) {
await db.socialId.update({ key: emailSocialId.key }, { verifiedOn: Date.now() })
}
return await login(ctx, db, branding, token, { email, password })
}
export async function leaveWorkspace (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: { account: AccountUuid }
): Promise<LoginInfo | null> {
const { account: targetAccount } = params
if (targetAccount == null || targetAccount === '') {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const { account, workspace } = decodeTokenVerbose(ctx, token)
ctx.info('Removing account from workspace', { account, workspace })
if (account == null || workspace == null) {
ctx.error('Account or workspace not provided for leaving', { account, workspace })
throw new PlatformError(new Status(Severity.ERROR, platform.status.InternalServerError, {}))
}
const initiatorRole = await db.getWorkspaceRole(account, workspace)
if (account !== targetAccount) {
if (initiatorRole == null || getRolePower(initiatorRole) < getRolePower(AccountRole.Maintainer)) {
ctx.error("Need to be at least maintainer to remove someone else's account from workspace", {
account,
workspace,
initiatorRole
})
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
}
await db.unassignWorkspace(targetAccount, workspace)
ctx.info('Account removed from workspace', { targetAccount, workspace })
if (account === targetAccount) {
const person = await db.person.findOne({ uuid: account })
if (person == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.InternalServerError, {}))
}
return {
account,
name: getPersonName(person),
token: generateToken(account, undefined)
}
}
return null
}
export async function changeUsername (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: {
first: string
last?: string
}
): Promise<void> {
const { first, last } = params
if (first == null || first === '') {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const { account } = decodeTokenVerbose(ctx, token)
await db.person.update({ uuid: account }, { firstName: first, lastName: last ?? '' })
ctx.info('Person name changed', { account, first, last })
}
export async function updateWorkspaceName (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: { name: string }
): Promise<void> {
const { name } = params
if (name == null || name === '') {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const { account, workspace } = decodeTokenVerbose(ctx, token)
const role = await db.getWorkspaceRole(account, workspace)
if (role == null || getRolePower(role) < getRolePower(AccountRole.Maintainer)) {
ctx.error('Need to be at least maintainer to update workspace name', { workspace, account, role })
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
await db.workspace.update(
{ uuid: workspace },
{
name
}
)
}
export async function deleteWorkspace (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string
): Promise<void> {
const { account, workspace } = decodeTokenVerbose(ctx, token)
const role = await db.getWorkspaceRole(account, workspace)
if (role !== AccountRole.Owner) {
ctx.error('Need to be an owner to delete a workspace', { workspace, account, role })
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
await db.workspaceStatus.update(
{ workspaceUuid: workspace },
{
isDisabled: true,
mode: 'pending-deletion'
}
)
}
/* =================================== */
/* ==========READ OPERATIONS========== */
/* =================================== */
export async function getRegionInfo (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string
): Promise<RegionInfo[]> {
return getRegions()
}
export async function getUserWorkspaces (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string
): Promise<WorkspaceInfoWithStatus[]> {
const { account } = decodeTokenVerbose(ctx, token)
return (await db.getAccountWorkspaces(account)).filter(
(ws) => isWorkspaceCreating(ws.status.mode) || !(isDeletingMode(ws.status.mode) || ws.status.isDisabled)
)
}
/**
* @public
*/
export async function getWorkspacesInfo (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: { ids: WorkspaceUuid[] }
): Promise<WorkspaceInfoWithStatus[]> {
const { ids } = params
if (ids == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const { account } = decodeTokenVerbose(ctx, token)
if (account !== systemAccountUuid) {
ctx.error('getWorkspaceInfos with wrong user', { account, token })
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
const workspaces: WorkspaceInfoWithStatus[] = await getWorkspacesInfoWithStatusByIds(db, ids)
workspaces.sort((a, b) => (b.status.lastVisit ?? 0) - (a.status.lastVisit ?? 0))
return workspaces
}
/**
* @public
*/
export async function updateLastVisit (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: { ids: WorkspaceUuid[] }
): Promise<void> {
const { ids } = params
if (ids == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const { account } = decodeTokenVerbose(ctx, token)
if (account !== systemAccountUuid) {
ctx.error('updateLastVisit with wrong user', { account, token })
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
await db.workspaceStatus.update({ workspaceUuid: { $in: ids } }, { lastVisit: Date.now() })
}
export async function getWorkspaceInfo (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: { updateLastVisit: boolean }
): Promise<WorkspaceInfoWithStatus> {
const { updateLastVisit = false } = params
const { account, workspace: workspaceUuid, extra } = decodeTokenVerbose(ctx, token)
const isGuest = extra?.guest === 'true'
const isAdmin = extra?.admin === 'true'
const skipAssignmentCheck = isGuest || account === systemAccountUuid
if (!skipAssignmentCheck) {
let role = await db.getWorkspaceRole(account, workspaceUuid)
if (role === null && isAdmin) {
role = AccountRole.Admin
}
if (role == null) {
ctx.error('Not a member of the workspace', { workspaceUuid, account })
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
}
const workspace = await getWorkspaceInfoWithStatusById(db, workspaceUuid)
// TODO: what should we return for archived?
if (workspace == null) {
ctx.error('Workspace not found', { workspaceUuid, account })
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid }))
}
if (workspace.status.isDisabled && isActiveMode(workspace.status.mode)) {
ctx.error('Workspace is disabled', { workspaceUuid, account })
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid }))
}
if (!isGuest && updateLastVisit && !isAdmin) {
await db.workspaceStatus.update({ workspaceUuid }, { lastVisit: Date.now() })
}
return workspace
}
/**
* Validates the token and returns the decoded account information.
*/
export async function getLoginInfoByToken (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params?: unknown,
meta?: Meta
): Promise<LoginInfo | WorkspaceLoginInfo> {
let accountUuid: AccountUuid
let workspaceUuid: WorkspaceUuid
let extra: any
try {
;({ account: accountUuid, workspace: workspaceUuid, extra } = decodeTokenVerbose(ctx, token))
} catch (err: any) {
Analytics.handleError(err)
ctx.error('Invalid token', { token })
throw new PlatformError(new Status(Severity.ERROR, platform.status.Unauthorized, {}))
}
if (accountUuid == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: accountUuid }))
}
const isDocGuest = accountUuid === GUEST_ACCOUNT && extra?.guest === 'true'
const isSystem = accountUuid === systemAccountUuid
const isAdmin = extra?.admin === 'true'
let socialId: SocialId | null = null
if (!isDocGuest && !isSystem) {
// Any confirmed social ID will do
socialId = (await getSocialIds(ctx, db, branding, token, { confirmed: true, includeDeleted: false }))[0]
if (socialId == null) {
return {
account: accountUuid
}
}
}
let person: Person | null
if (isDocGuest) {
person = {
uuid: accountUuid,
firstName: 'Guest',
lastName: 'User'
}
} else if (isSystem) {
person = {
uuid: accountUuid,
firstName: 'System',
lastName: 'User'
}
} else {
person = await db.person.findOne({ uuid: accountUuid })
}
if (person == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.InternalServerError, {}))
}
const loginInfo = {
account: accountUuid,
name: getPersonName(person),
socialId: socialId?._id,
token
}
if (!isSystem) {
void setTimezoneIfNotDefined(ctx, db, accountUuid, null, meta)
}
if (workspaceUuid != null && workspaceUuid !== '') {
const workspace = await getWorkspaceById(db, workspaceUuid)
if (workspace == null) {
ctx.error('Workspace not found', { workspaceUuid, account: accountUuid })
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid }))
}
const endpointKind = meta?.clientNetworkPosition === 'internal' ? EndpointKind.Internal : EndpointKind.External
const endpoint = getEndpoint(workspace.uuid, workspace.region, endpointKind)
if (isDocGuest) {
return {
...loginInfo,
workspace: workspaceUuid,
endpoint,
role: AccountRole.DocGuest
}
}
let role = await getWorkspaceRole(db, accountUuid, workspace.uuid)
if (role === null && isAdmin) {
role = AccountRole.Admin
}
if (role == null) {
// User might have been removed from the workspace
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
return {
...loginInfo,
workspace: workspace.uuid,
workspaceDataId: workspace.dataId,
endpoint,
role
}
} else {
return loginInfo
}
}
/**
* Validates the token and returns the decoded account information.
*/
export async function getLoginWithWorkspaceInfo (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string
): Promise<LoginInfoWithWorkspaces> {
let accountUuid: AccountUuid
let extra: any
let workspace: WorkspaceUuid | undefined
try {
;({ account: accountUuid, extra, workspace } = decodeTokenVerbose(ctx, token))
} catch (err: any) {
Analytics.handleError(err)
ctx.error('Invalid token', { token })
throw new PlatformError(new Status(Severity.ERROR, platform.status.Unauthorized, {}))
}
if (accountUuid == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: accountUuid }))
}
const isDocGuest = accountUuid === GUEST_ACCOUNT && extra?.guest === 'true'
const isSystem = accountUuid === systemAccountUuid
let socialIds: SocialId[] = []
if (!isDocGuest && !isSystem) {
// Any confirmed social ID will do
socialIds = await db.socialId.find({ personUuid: accountUuid, verifiedOn: { $gt: 0 } })
if (socialIds.length === 0) {
return {
account: accountUuid,
workspaces: {},
socialIds: []
}
}
}
let person: Person | null
if (isDocGuest) {
person = {
uuid: accountUuid,
firstName: 'Guest',
lastName: 'User'
}
} else if (isSystem) {
person = {
uuid: accountUuid,
firstName: 'System',
lastName: 'User'
}
} else {
person = await db.person.findOne({ uuid: accountUuid })
}
if (person == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.InternalServerError, {}))
}
const userWorkspaces = (await db.getAccountWorkspaces(accountUuid)).filter((it) => isActiveMode(it.status.mode))
const roles: Map<WorkspaceUuid, AccountRole | null> = await getWorkspaceRoles(db, accountUuid)
const info = getEndpointInfo()
const loginInfo: LoginInfoWithWorkspaces = {
account: accountUuid,
name: getPersonName(person),
socialId: socialIds[0]?._id,
token,
workspaces: Object.fromEntries(
isSystem || isDocGuest
? []
: userWorkspaces.map((it, idx) => [
it.uuid,
{
url: it.url,
dataId: it.dataId,
mode: it.status.mode,
endpoint: getWorkspaceEndpoint(info, it.uuid, it.region),
role: roles.get(it.uuid) ?? null,
version: {
versionMajor: it.status.versionMajor,
versionMinor: it.status.versionMinor,
versionPatch: it.status.versionPatch
},
progress: it.status.processingProgress
}
])
),
socialIds
}
for (const ws of userWorkspaces) {
if (ws.uuid === workspace) {
await db.workspaceStatus.update({ workspaceUuid: workspace }, { lastVisit: Date.now() })
break
}
}
return loginInfo
}
export async function getSocialIds (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: { confirmed: boolean, includeDeleted: boolean }
): Promise<SocialId[]> {
const { confirmed = true, includeDeleted = false } = params
const { account } = decodeTokenVerbose(ctx, token)
// do not expose not-confirmed social ids for now
if (!confirmed) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
const socialIds = await db.socialId.find({ personUuid: account, verifiedOn: { $gt: 0 } })
return includeDeleted ? socialIds : socialIds.filter((si) => si.isDeleted !== true)
}
export async function isReadOnlyGuest (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string
): Promise<boolean> {
const { account } = decodeTokenVerbose(ctx, token)
return account === readOnlyGuestAccountUuid
}
export async function getPerson (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string
): Promise<Person> {
const { account } = decodeTokenVerbose(ctx, token)
const person = await db.person.findOne({ uuid: account })
if (person == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.PersonNotFound, { person: account }))
}
return person
}
export async function findPersonBySocialId (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: { socialId: PersonId, requireAccount?: boolean }
): Promise<PersonUuid | undefined> {
const { socialId, requireAccount } = params
if (socialId == null || socialId === '') {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
decodeTokenVerbose(ctx, token)
const socialIdObj = await db.socialId.findOne({ _id: socialId })
if (socialIdObj == null) {
return
}
// TODO: combine into one request with join
if (requireAccount === true) {
const account = await db.account.findOne({ uuid: socialIdObj.personUuid as AccountUuid })
if (account == null) {
return
}
}
return socialIdObj.personUuid
}
export async function findSocialIdBySocialKey (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: { socialKey: string, requireAccount?: boolean }
): Promise<PersonId | undefined> {
const { socialKey, requireAccount } = params
decodeTokenVerbose(ctx, token)
if (socialKey == null || socialKey === '') {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const socialIdObj = await db.socialId.findOne({ key: socialKey })
if (socialIdObj == null) {
return
}
// TODO: combine into one request with join
if (requireAccount === true) {
const account = await db.account.findOne({ uuid: socialIdObj.personUuid as AccountUuid })
if (account == null) {
return
}
}
return socialIdObj._id
}
export async function getWorkspaceMembers (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string
): Promise<WorkspaceMemberInfo[]> {
const { account, workspace } = decodeTokenVerbose(ctx, token)
if (workspace === null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid: workspace }))
}
const accRole = await getWorkspaceRole(db, account, workspace)
if (accRole == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
return await db.getWorkspaceMembers(workspace)
}
export async function getAccountInfo (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: { accountId: AccountUuid }
): Promise<AccountInfo> {
const { accountId } = params
if (accountId == null || accountId === '') {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
decodeTokenVerbose(ctx, token)
const account = await getAccount(db, accountId)
if (account === undefined || account === null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {}))
}
return { timezone: account?.timezone, locale: account?.locale }
}
export async function ensurePerson (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: {
socialType: SocialIdType
socialValue: string
firstName: string
lastName: string
}
): Promise<{ uuid: PersonUuid, socialId: PersonId }> {
const { account, workspace, extra } = decodeTokenVerbose(ctx, token)
const allowedService = verifyAllowedServices(
['tool', 'workspace', 'schedule', 'mail', 'github', 'hulygram'],
extra,
false
)
if (!allowedService) {
const callerRole = await getWorkspaceRole(db, account, workspace)
verifyAllowedRole(callerRole, AccountRole.User, extra)
}
const { socialType, socialValue, firstName, lastName } = params
const trimmedFirst = firstName == null ? '' : firstName.trim()
const trimmedLast = lastName == null ? '' : lastName.trim()
const normalizedValue = normalizeValue(socialValue ?? '')
if (!Object.values(SocialIdType).includes(socialType) || trimmedFirst.length === 0 || normalizedValue.length === 0) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const socialId = await db.socialId.findOne({ type: socialType, value: normalizedValue })
if (socialId != null) {
return { uuid: socialId.personUuid, socialId: socialId._id }
}
const personUuid = await db.person.insertOne({ firstName: trimmedFirst, lastName: trimmedLast })
const newSocialId = await db.socialId.insertOne({ type: socialType, value: normalizedValue, personUuid })
return { uuid: personUuid, socialId: newSocialId }
}
async function getMailboxOptions (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string
): Promise<MailboxOptions> {
decodeTokenVerbose(ctx, token)
return {
availableDomains: process.env.MAILBOX_DOMAINS?.split(',') ?? [],
minNameLength: parseInt(process.env.MAILBOX_MIN_NAME_LENGTH ?? '6'),
maxNameLength: parseInt(process.env.MAILBOX_MAX_NAME_LENGTH ?? '30'),
maxMailboxCount: parseInt(process.env.MAILBOX_MAX_COUNT_PER_ACCOUNT ?? '1')
}
}
async function createMailbox (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: {
name: string
domain: string
}
): Promise<{ mailbox: string, socialId: PersonId }> {
const { name, domain } = params
if (name == null || name === '' || domain == null || domain === '') {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const { account } = decodeTokenVerbose(ctx, token)
const normalizedName = cleanEmail(name)
const normalizedDomain = cleanEmail(domain)
const mailbox = normalizedName + '@' + normalizedDomain
const opts = await getMailboxOptions(ctx, db, branding, token)
if (normalizedName.length === 0 || normalizedDomain.length === 0 || !isEmail(mailbox)) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.MailboxError, { reason: 'invalid-name' }))
}
if (!opts.availableDomains.includes(normalizedDomain)) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.MailboxError, { reason: 'domain-not-found' }))
}
if (normalizedName.length < opts.minNameLength || normalizedName.length > opts.maxNameLength) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.MailboxError, { reason: 'name-rules-violated' }))
}
if ((await db.mailbox.findOne({ mailbox })) !== null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.MailboxError, { reason: 'mailbox-exists' }))
}
const mailboxes = await db.mailbox.find({ accountUuid: account })
if (mailboxes.length >= opts.maxMailboxCount) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.MailboxError, { reason: 'mailbox-count-limit' }))
}
await db.mailbox.insertOne({ accountUuid: account, mailbox })
await db.mailboxSecret.insertOne({ mailbox, secret: generatePassword() })
const socialId = await addSocialIdBase(db, account, SocialIdType.EMAIL, mailbox, true)
ctx.info('Mailbox created', { mailbox, account, socialId })
return { mailbox, socialId }
}
async function getMailboxes (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string
): Promise<Mailbox[]> {
const { account } = decodeTokenVerbose(ctx, token)
return await db.mailbox.find({ accountUuid: account })
}
async function deleteMailbox (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: { mailbox: string }
): Promise<void> {
if (params.mailbox == null || params.mailbox === '') {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const { account } = decodeTokenVerbose(ctx, token)
const mailbox = cleanEmail(params.mailbox)
if (!isEmail(mailbox)) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.MailboxError, { reason: 'invalid-name' }))
}
const mb = await db.mailbox.findOne({ mailbox, accountUuid: account })
if (mb == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.MailboxError, { reason: 'mailbox-not-found' }))
}
await db.mailboxSecret.deleteMany({ mailbox })
await db.mailbox.deleteMany({ mailbox })
await doReleaseSocialId(db, account, SocialIdType.EMAIL, mailbox, `deleteMailbox@${account}`)
ctx.info('Mailbox deleted', { mailbox, account })
}
async function exchangeGuestToken (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string
): Promise<string> {
const tokenObj = decodeTokenVerbose(ctx, token)
if (tokenObj.account == null) {
// Check if it's old guest token
const oldGuestEmail = '#guest@hc.engineering'
const { linkId, guest, email, workspace: workspaceDataId } = tokenObj as any
if (linkId == null || guest == null || email !== oldGuestEmail || workspaceDataId == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const workspace = await getWorkspaceByDataId(db, workspaceDataId)
if (workspace == null) {
throw new PlatformError(
new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUrl: workspaceDataId })
)
}
return generateToken(GUEST_ACCOUNT as PersonUuid, workspace.uuid, { linkId, guest: 'true' })
}
return token
}
async function addEmailSocialId (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: {
email: string
}
): Promise<OtpInfo> {
const { email } = params
const { account } = decodeTokenVerbose(ctx, token)
if (email == null || email === '') {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
if (account == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account }))
}
const normalizedEmail = normalizeValue(email)
const existing = await db.socialId.findOne({ type: SocialIdType.EMAIL, value: normalizedEmail })
// This schema should be applied to all types in general, they should only differ by the verification process.
// If none exists, create a new one and proceed to verification
// If exists only for person without account - will be able to merge person to the account, proceed to verification
// If exists for this account but not verified - proceed to verification right away
// If exists for this account and verified - throw an error (already exists)
// If exists for another account and not verified - will move only this id to the current account, proceed to verification
// If exists for another account and verified - throw an error for now, support merge accounts later, maybe through a different procedure
let targetSocialId: SocialId
if (existing != null) {
if (existing.verifiedOn != null) {
throw new PlatformError(
new Status(Severity.ERROR, platform.status.SocialIdAlreadyExists, { value: email, type: SocialIdType.EMAIL })
)
}
targetSocialId = existing
} else {
const newSocialId = {
type: SocialIdType.EMAIL,
value: normalizedEmail,
personUuid: account
}
const _id = await db.socialId.insertOne(newSocialId)
targetSocialId = { ...newSocialId, _id, key: buildSocialIdString(newSocialId) }
}
return await sendOtp(ctx, db, branding, targetSocialId)
}
export async function releaseSocialId (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: { personUuid?: PersonUuid, type: SocialIdType, value: string, deleteIntegrations?: boolean }
): Promise<SocialId> {
const { account, extra } = decodeTokenVerbose(ctx, token)
let { personUuid } = params
const { type, value, deleteIntegrations } = params
if (!Object.values(SocialIdType).includes(type) || value == null || value === '') {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const allowedService = verifyAllowedServices(['github', 'tool', 'workspace'], extra, false)
if (!allowedService) {
if (personUuid != null && personUuid !== account) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
} else {
personUuid = account
}
}
if (personUuid == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
if (!allowedService) {
// User should always have at least one Huly and one "login" social id
// so do not allow releasing last ones
const socialIds = await db.socialId.find({ personUuid, verifiedOn: { $gt: 0 }, isDeleted: { $ne: true } })
const afterRemoval = socialIds.filter((it) => it.type !== type || it.value !== value)
if (afterRemoval.filter((it) => it.type === SocialIdType.HULY).length === 0) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
if (afterRemoval.filter((it) => loginSocialTypes.includes(it.type)).length === 0) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
}
return await doReleaseSocialId(db, personUuid, type, value, extra?.service ?? account, deleteIntegrations)
}
export async function deleteAccount (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: { uuid?: AccountUuid }
): Promise<void> {
const { extra } = decodeTokenVerbose(ctx, token)
const isAdmin = extra?.admin === 'true'
if (!isAdmin) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
const { uuid } = params
if (uuid == null || uuid === '') {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
await db.deleteAccount(uuid)
await db.accountEvent.insertOne({
accountUuid: uuid,
eventType: AccountEventType.ACCOUNT_DELETED,
time: Date.now()
})
}
export type AccountMethods =
| AccountServiceMethods
| 'login'
| 'loginOtp'
| 'loginAsGuest'
| 'signUp'
| 'signUpOtp'
| 'validateOtp'
| 'createWorkspace'
| 'createInvite'
| 'createInviteLink'
| 'sendInvite'
| 'resendInvite'
| 'selectWorkspace'
| 'join'
| 'checkJoin'
| 'checkAutoJoin'
| 'signUpJoin'
| 'confirm'
| 'changePassword'
| 'requestPasswordReset'
| 'restorePassword'
| 'leaveWorkspace'
| 'changeUsername'
| 'updateWorkspaceName'
| 'deleteWorkspace'
| 'getRegionInfo'
| 'getUserWorkspaces'
| 'getWorkspaceInfo'
| 'getWorkspacesInfo'
| 'updateLastVisit'
| 'getLoginInfoByToken'
| 'getLoginWithWorkspaceInfo'
| 'getSocialIds'
| 'getPerson'
| 'getWorkspaceMembers'
| 'updateWorkspaceRole'
| 'updateAllowReadOnlyGuests'
| 'updateAllowGuestSignUp'
| 'findPersonBySocialId'
| 'findSocialIdBySocialKey'
| 'ensurePerson'
| 'exchangeGuestToken'
| 'getMailboxOptions'
| 'createMailbox'
| 'getMailboxes'
| 'deleteMailbox'
| 'getAccountInfo'
| 'isReadOnlyGuest'
| 'addEmailSocialId'
| 'releaseSocialId'
| 'deleteAccount'
/**
* @public
*/
export function getMethods (hasSignUp: boolean = true): Partial<Record<AccountMethods, AccountMethodHandler>> {
return {
/* OPERATIONS */
login: wrap(login),
loginOtp: wrap(loginOtp),
loginAsGuest: wrap(loginAsGuest),
...(hasSignUp ? { signUp: wrap(signUp) } : {}),
...(hasSignUp ? { signUpOtp: wrap(signUpOtp) } : {}),
validateOtp: wrap(validateOtp),
createWorkspace: wrap(createWorkspace),
createInvite: wrap(createInvite),
createInviteLink: wrap(createInviteLink),
sendInvite: wrap(sendInvite),
resendInvite: wrap(resendInvite),
selectWorkspace: wrap(selectWorkspace),
join: wrap(join),
checkJoin: wrap(checkJoin),
checkAutoJoin: wrap(checkAutoJoin),
signUpJoin: wrap(signUpJoin),
confirm: wrap(confirm),
changePassword: wrap(changePassword),
requestPasswordReset: wrap(requestPasswordReset),
restorePassword: wrap(restorePassword),
leaveWorkspace: wrap(leaveWorkspace),
changeUsername: wrap(changeUsername),
updateWorkspaceName: wrap(updateWorkspaceName),
deleteWorkspace: wrap(deleteWorkspace),
updateWorkspaceRole: wrap(updateWorkspaceRole),
updateAllowReadOnlyGuests: wrap(updateAllowReadOnlyGuests),
updateAllowGuestSignUp: wrap(updateAllowGuestSignUp),
createMailbox: wrap(createMailbox),
getMailboxes: wrap(getMailboxes),
deleteMailbox: wrap(deleteMailbox),
ensurePerson: wrap(ensurePerson),
exchangeGuestToken: wrap(exchangeGuestToken),
addEmailSocialId: wrap(addEmailSocialId),
releaseSocialId: wrap(releaseSocialId),
deleteAccount: wrap(deleteAccount),
/* READ OPERATIONS */
getRegionInfo: wrap(getRegionInfo),
getUserWorkspaces: wrap(getUserWorkspaces),
getWorkspaceInfo: wrap(getWorkspaceInfo),
getWorkspacesInfo: wrap(getWorkspacesInfo),
updateLastVisit: wrap(updateLastVisit),
getLoginInfoByToken: wrap(getLoginInfoByToken),
getLoginWithWorkspaceInfo: wrap(getLoginWithWorkspaceInfo),
getSocialIds: wrap(getSocialIds),
getPerson: wrap(getPerson),
findPersonBySocialId: wrap(findPersonBySocialId),
findSocialIdBySocialKey: wrap(findSocialIdBySocialKey),
getWorkspaceMembers: wrap(getWorkspaceMembers),
getMailboxOptions: wrap(getMailboxOptions),
getAccountInfo: wrap(getAccountInfo),
isReadOnlyGuest: wrap(isReadOnlyGuest),
/* SERVICE METHODS */
...getServiceMethods()
}
}
export * from './plugin'
export * from './serviceOperations'
export default accountPlugin