mirror of
https://github.com/hcengineering/platform.git
synced 2025-03-14 18:15:01 +00:00
1240 lines
37 KiB
TypeScript
1240 lines
37 KiB
TypeScript
//
|
|
// Copyright © 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 {
|
|
Branding,
|
|
concatLink,
|
|
generateId,
|
|
groupByArray,
|
|
MeasureContext,
|
|
AccountRole,
|
|
roleOrder,
|
|
SocialIdType,
|
|
WorkspaceUuid,
|
|
WorkspaceMode,
|
|
SocialKey,
|
|
systemAccountUuid,
|
|
type WorkspaceInfoWithStatus as WorkspaceInfoWithStatusCore,
|
|
isActiveMode,
|
|
type PersonUuid,
|
|
type PersonId,
|
|
type Person,
|
|
buildSocialIdString
|
|
} from '@hcengineering/core'
|
|
import { getMongoClient } from '@hcengineering/mongo' // TODO: get rid of this import later
|
|
import platform, { getMetadata, PlatformError, Severity, Status, translate } from '@hcengineering/platform'
|
|
import { getDBClient } from '@hcengineering/postgres'
|
|
import otpGenerator from 'otp-generator'
|
|
import { pbkdf2Sync, randomBytes } from 'crypto'
|
|
|
|
import { MongoAccountDB } from './collections/mongo'
|
|
import { PostgresAccountDB } from './collections/postgres'
|
|
import { accountPlugin } from './plugin'
|
|
import { sharedPipelineContextVars } from '@hcengineering/server-pipeline'
|
|
import {
|
|
AccountMethodHandler,
|
|
OtpInfo,
|
|
WorkspaceInvite,
|
|
WorkspaceInfoWithStatus,
|
|
type Account,
|
|
type AccountDB,
|
|
type RegionInfo,
|
|
type SocialId,
|
|
type Workspace,
|
|
LoginInfo,
|
|
WorkspaceLoginInfo,
|
|
WorkspaceStatus,
|
|
AccountEventType
|
|
} from './types'
|
|
import { Analytics } from '@hcengineering/analytics'
|
|
import { TokenError, decodeTokenVerbose, generateToken } from '@hcengineering/server-token'
|
|
|
|
export const GUEST_ACCOUNT = 'b6996120-416f-49cd-841e-e4a5d2e49c9b'
|
|
|
|
export async function getAccountDB (uri: string, dbNs?: string): Promise<[AccountDB, () => void]> {
|
|
const isMongo = uri.startsWith('mongodb://')
|
|
|
|
if (isMongo) {
|
|
const client = getMongoClient(uri)
|
|
const db = (await client.getClient()).db(dbNs ?? 'global-account')
|
|
const mongoAccount = new MongoAccountDB(db)
|
|
|
|
await mongoAccount.init()
|
|
|
|
return [
|
|
mongoAccount,
|
|
() => {
|
|
client.close()
|
|
}
|
|
]
|
|
} else {
|
|
const client = getDBClient(sharedPipelineContextVars, uri)
|
|
const pgClient = await client.getClient()
|
|
// TODO: if dbNs is provided put tables in that schema
|
|
const pgAccount = new PostgresAccountDB(pgClient)
|
|
|
|
let error = false
|
|
|
|
do {
|
|
try {
|
|
await pgAccount.init()
|
|
error = false
|
|
} catch (e) {
|
|
console.error('Error while initializing postgres account db', e)
|
|
error = true
|
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
}
|
|
} while (error)
|
|
|
|
return [
|
|
pgAccount,
|
|
() => {
|
|
client.close()
|
|
}
|
|
]
|
|
}
|
|
}
|
|
|
|
export function getRolePower (role: AccountRole): number {
|
|
return roleOrder[role]
|
|
}
|
|
|
|
export function wrap (
|
|
accountMethod: (ctx: MeasureContext, db: AccountDB, branding: Branding | null, ...args: any[]) => Promise<any>
|
|
): AccountMethodHandler {
|
|
return async function (
|
|
ctx: MeasureContext,
|
|
db: AccountDB,
|
|
branding: Branding | null,
|
|
request: any,
|
|
token?: string
|
|
): Promise<any> {
|
|
return await accountMethod(ctx, db, branding, token, { ...request.params })
|
|
.then((result) => ({ id: request.id, result }))
|
|
.catch((err) => {
|
|
const status =
|
|
err instanceof PlatformError
|
|
? err.status
|
|
: new Status(Severity.ERROR, platform.status.InternalServerError, {})
|
|
|
|
if (err instanceof TokenError) {
|
|
// Let's send un authorized
|
|
return {
|
|
error: new Status(Severity.ERROR, platform.status.Unauthorized, {})
|
|
}
|
|
}
|
|
|
|
if (status.code === platform.status.InternalServerError) {
|
|
Analytics.handleError(err)
|
|
ctx.error('error', { status, err })
|
|
} else {
|
|
ctx.error('error', { status })
|
|
}
|
|
|
|
return {
|
|
error: status
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns a hash code for a string.
|
|
* (Compatible to Java's String.hashCode())
|
|
*
|
|
* The hash code for a string object is computed as
|
|
* s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
|
|
* using number arithmetic, where s[i] is the i th character
|
|
* of the given string, n is the length of the string,
|
|
* and ^ indicates exponentiation.
|
|
* (The hash value of the empty string is zero.)
|
|
*
|
|
*/
|
|
function hashWorkspace (dbWorkspaceName: string): number {
|
|
return [...dbWorkspaceName].reduce((hash, c) => (Math.imul(31, hash) + c.charCodeAt(0)) | 0, 0)
|
|
}
|
|
|
|
export enum EndpointKind {
|
|
Internal,
|
|
External
|
|
}
|
|
|
|
const toTransactor = (line: string): { internalUrl: string, region: string, externalUrl: string } => {
|
|
const [internalUrl, externalUrl, region] = line
|
|
.split(';')
|
|
.map((it) => it.trim())
|
|
.map((it) => (it.length === 0 ? undefined : it))
|
|
return { internalUrl: internalUrl ?? '', region: region ?? '', externalUrl: externalUrl ?? internalUrl ?? '' }
|
|
}
|
|
|
|
/**
|
|
* Internal. Exported for testing only.
|
|
* @returns list of endpoints
|
|
*/
|
|
export const getEndpoints = (): string[] => {
|
|
const transactorsUrl = getMetadata(accountPlugin.metadata.Transactors)
|
|
if (transactorsUrl === undefined) {
|
|
throw new Error('Please provide transactor endpoint url')
|
|
}
|
|
const endpoints = transactorsUrl
|
|
.split(',')
|
|
.map((it) => it.trim())
|
|
.filter((it) => it.length > 0)
|
|
|
|
if (endpoints.length === 0) {
|
|
throw new Error('Please provide transactor endpoint url')
|
|
}
|
|
return endpoints
|
|
}
|
|
|
|
// Info is static, so no need to calculate it every time.
|
|
let regionInfo: RegionInfo[] = []
|
|
|
|
export const getRegions = (): RegionInfo[] => {
|
|
if (regionInfo.length === 0) {
|
|
regionInfo = _getRegions()
|
|
}
|
|
return regionInfo
|
|
}
|
|
|
|
/**
|
|
* Internal. Exported for tests only.
|
|
* @returns list of endpoints
|
|
*/
|
|
export const _getRegions = (): RegionInfo[] => {
|
|
let _regionInfo: RegionInfo[] = []
|
|
const endpoints = getEndpoints()
|
|
.map(toTransactor)
|
|
.map((it) => ({ region: it.region.trim(), name: '' }))
|
|
if (process.env.REGION_INFO !== undefined) {
|
|
_regionInfo = process.env.REGION_INFO.split(';')
|
|
.map((it) => it.split('|'))
|
|
.map((it) => ({ region: it[0].trim(), name: it[1].trim() }))
|
|
// We need to add all endpoints if they are not in info.
|
|
for (const endpoint of endpoints) {
|
|
if (_regionInfo.find((it) => it.region === endpoint.region) === undefined) {
|
|
_regionInfo.push(endpoint)
|
|
}
|
|
}
|
|
} else {
|
|
_regionInfo = endpoints
|
|
}
|
|
|
|
return _regionInfo
|
|
}
|
|
|
|
export const getEndpoint = (
|
|
ctx: MeasureContext,
|
|
workspace: string,
|
|
region: string | undefined,
|
|
kind: EndpointKind
|
|
): string => {
|
|
const byRegions = groupByArray(getEndpoints().map(toTransactor), (it) => it.region)
|
|
let transactors = (byRegions.get(region ?? '') ?? [])
|
|
.map((it) => (kind === EndpointKind.Internal ? it.internalUrl : it.externalUrl))
|
|
.flat()
|
|
|
|
// This is really bad
|
|
if (transactors.length === 0) {
|
|
ctx.error('No transactors for the target region, will use default region', { group: region })
|
|
|
|
transactors = (byRegions.get('') ?? [])
|
|
.map((it) => (kind === EndpointKind.Internal ? it.internalUrl : it.externalUrl))
|
|
.flat()
|
|
}
|
|
|
|
if (transactors.length === 0) {
|
|
ctx.error('No transactors for the default region')
|
|
throw new Error('Please provide transactor endpoint url')
|
|
}
|
|
|
|
const hash = hashWorkspace(workspace)
|
|
return transactors[Math.abs(hash % transactors.length)]
|
|
}
|
|
|
|
export function getAllTransactors (kind: EndpointKind): string[] {
|
|
const transactorsUrl = getMetadata(accountPlugin.metadata.Transactors)
|
|
if (transactorsUrl === undefined) {
|
|
throw new Error('Please provide transactor endpoint url')
|
|
}
|
|
const endpoints = transactorsUrl
|
|
.split(',')
|
|
.map((it) => it.trim())
|
|
.filter((it) => it.length > 0)
|
|
|
|
if (endpoints.length === 0) {
|
|
throw new Error('Please provide transactor endpoint url')
|
|
}
|
|
|
|
const toTransactor = (line: string): { internalUrl: string, group: string, externalUrl: string } => {
|
|
const [internalUrl, externalUrl, group] = line.split(';')
|
|
return { internalUrl, group: group ?? '', externalUrl: externalUrl ?? internalUrl }
|
|
}
|
|
|
|
return endpoints.map(toTransactor).map((it) => (kind === EndpointKind.External ? it.externalUrl : it.internalUrl))
|
|
}
|
|
|
|
export function hashWithSalt (password: string, salt: Buffer): Buffer {
|
|
// remove "as any" when types in node will be fixed
|
|
return pbkdf2Sync(password, salt as any, 1000, 32, 'sha256')
|
|
}
|
|
|
|
export function verifyPassword (password: string, hash?: Buffer | null, salt?: Buffer | null): boolean {
|
|
if (hash == null || salt == null) {
|
|
return false
|
|
}
|
|
|
|
// remove "as any" when types in node will be fixed
|
|
return Buffer.compare(hash as any, hashWithSalt(password, salt) as any) === 0
|
|
}
|
|
|
|
export function cleanEmail (email: string): string {
|
|
return email.toLowerCase().trim()
|
|
}
|
|
|
|
export function isEmail (email: string): boolean {
|
|
// RFC 5322 compliant email regex
|
|
const EMAIL_REGEX =
|
|
/^[a-zA-Z0-9](?:[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-](?:\.?[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-])*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-zA-Z0-9-]*[a-zA-Z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])$/ // eslint-disable-line no-control-regex
|
|
return EMAIL_REGEX.test(email)
|
|
}
|
|
|
|
export function isShallowEqual (obj1: Record<string, any>, obj2: Record<string, any>): boolean {
|
|
const keys1 = Object.keys(obj1)
|
|
const keys2 = Object.keys(obj2)
|
|
|
|
return keys1.length === keys2.length && keys1.every((k) => obj1[k] === obj2[k])
|
|
}
|
|
|
|
export async function setPassword (
|
|
ctx: MeasureContext,
|
|
db: AccountDB,
|
|
branding: Branding | null,
|
|
personUuid: PersonUuid,
|
|
password: string
|
|
): Promise<void> {
|
|
if (password == null || password === '') {
|
|
return
|
|
}
|
|
|
|
const salt = randomBytes(32)
|
|
await db.setPassword(personUuid, hashWithSalt(password, salt), salt)
|
|
}
|
|
|
|
export async function generateUniqueOtp (db: AccountDB): Promise<string> {
|
|
let exists = true
|
|
let code = ''
|
|
|
|
do {
|
|
code = otpGenerator.generate(6, {
|
|
upperCaseAlphabets: false,
|
|
lowerCaseAlphabets: false,
|
|
specialChars: false
|
|
})
|
|
|
|
exists = (await db.otp.findOne({ code })) != null
|
|
} while (exists)
|
|
|
|
return code
|
|
}
|
|
|
|
export async function sendOtp (
|
|
ctx: MeasureContext,
|
|
db: AccountDB,
|
|
branding: Branding | null,
|
|
socialId: SocialId
|
|
): Promise<OtpInfo> {
|
|
const ts = Date.now()
|
|
const otpData = (await db.otp.find({ socialId: socialId.key }, { createdOn: 'descending' }, 1))[0]
|
|
const retryDelay = getMetadata(accountPlugin.metadata.OtpRetryDelaySec) ?? 30
|
|
|
|
if (otpData !== undefined && otpData.expiresOn > ts && otpData.createdOn + retryDelay * 1000 > ts) {
|
|
return { sent: true, retryOn: otpData.createdOn + retryDelay * 1000 }
|
|
}
|
|
|
|
let sendMethod: (ctx: MeasureContext, branding: Branding | null, code: string, target: string) => Promise<void>
|
|
|
|
switch (socialId.type) {
|
|
case SocialIdType.EMAIL: {
|
|
sendMethod = sendOtpEmail
|
|
break
|
|
}
|
|
default:
|
|
throw new Error('Unsupported OTP social id type')
|
|
}
|
|
|
|
const retryDelayMs = (getMetadata(accountPlugin.metadata.OtpRetryDelaySec) ?? 30) * 1000
|
|
const ttlMs = (getMetadata(accountPlugin.metadata.OtpTimeToLiveSec) ?? 60) * 1000
|
|
const code = await generateUniqueOtp(db)
|
|
|
|
await sendMethod(ctx, branding, code, socialId.value)
|
|
await db.otp.insertOne({ socialId: socialId.key, code, expiresOn: ts + ttlMs, createdOn: ts })
|
|
|
|
return { sent: true, retryOn: ts + retryDelayMs }
|
|
}
|
|
|
|
export async function sendOtpEmail (
|
|
ctx: MeasureContext,
|
|
branding: Branding | null,
|
|
otp: string,
|
|
email: string
|
|
): Promise<void> {
|
|
const sesURL = getMetadata(accountPlugin.metadata.SES_URL)
|
|
if (sesURL === undefined || sesURL === '') {
|
|
ctx.error('Please provide email service url to enable email otp')
|
|
return
|
|
}
|
|
const sesAuth = getMetadata(accountPlugin.metadata.SES_AUTH_TOKEN)
|
|
|
|
const lang = branding?.language
|
|
const app = branding?.title ?? getMetadata(accountPlugin.metadata.ProductName)
|
|
|
|
const text = await translate(accountPlugin.string.OtpText, { code: otp, app }, lang)
|
|
const html = await translate(accountPlugin.string.OtpHTML, { code: otp, app }, lang)
|
|
const subject = await translate(accountPlugin.string.OtpSubject, { code: otp, app }, lang)
|
|
|
|
const to = email
|
|
await fetch(concatLink(sesURL, '/send'), {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...(sesAuth != null ? { Authorization: `Bearer ${sesAuth}` } : {})
|
|
},
|
|
body: JSON.stringify({
|
|
text,
|
|
html,
|
|
subject,
|
|
to
|
|
})
|
|
})
|
|
}
|
|
|
|
export async function isOtpValid (db: AccountDB, socialId: string, code: string): Promise<boolean> {
|
|
const otpData = await db.otp.findOne({ socialId, code })
|
|
|
|
return (otpData?.expiresOn ?? 0) > Date.now()
|
|
}
|
|
|
|
export async function createAccount (
|
|
db: AccountDB,
|
|
personUuid: PersonUuid,
|
|
confirmed = false,
|
|
createdOn = Date.now()
|
|
): Promise<void> {
|
|
// Create Huly social id and account
|
|
// Currently, it's always created along with the account but never confirmed.
|
|
// What's the actual use case for it?
|
|
await db.socialId.insertOne({
|
|
type: SocialIdType.HULY,
|
|
value: personUuid,
|
|
personUuid,
|
|
...(confirmed ? { verifiedOn: Date.now() } : {})
|
|
})
|
|
await db.account.insertOne({ uuid: personUuid })
|
|
await db.accountEvent.insertOne({
|
|
accountUuid: personUuid,
|
|
eventType: AccountEventType.ACCOUNT_CREATED,
|
|
time: createdOn
|
|
})
|
|
}
|
|
|
|
export async function signUpByEmail (
|
|
ctx: MeasureContext,
|
|
db: AccountDB,
|
|
branding: Branding | null,
|
|
email: string,
|
|
password: string,
|
|
firstName: string,
|
|
lastName: string,
|
|
confirmed = false
|
|
): Promise<PersonUuid> {
|
|
const normalizedEmail = cleanEmail(email)
|
|
|
|
const emailSocialId = await getEmailSocialId(db, normalizedEmail)
|
|
let personUuid: PersonUuid
|
|
|
|
if (emailSocialId !== null) {
|
|
const existingAccount = await db.account.findOne({ uuid: emailSocialId.personUuid })
|
|
|
|
if (existingAccount !== null) {
|
|
ctx.error('An account with the provided email already exists', { email })
|
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountAlreadyExists, {}))
|
|
}
|
|
|
|
personUuid = emailSocialId.personUuid
|
|
// Person exists, but may have different name, need to update with what's been provided
|
|
await db.person.updateOne({ uuid: personUuid }, { firstName, lastName })
|
|
} else {
|
|
// There's no person we can link to this email, so we need to create a new one
|
|
personUuid = await db.person.insertOne({ firstName, lastName })
|
|
await db.socialId.insertOne({
|
|
type: SocialIdType.EMAIL,
|
|
value: normalizedEmail,
|
|
personUuid,
|
|
...(confirmed ? { verifiedOn: Date.now() } : {})
|
|
})
|
|
}
|
|
|
|
await createAccount(db, personUuid, confirmed)
|
|
await setPassword(ctx, db, branding, personUuid, password)
|
|
|
|
return personUuid
|
|
}
|
|
|
|
export async function selectWorkspace (
|
|
ctx: MeasureContext,
|
|
db: AccountDB,
|
|
branding: Branding | null,
|
|
token: string | undefined,
|
|
params: {
|
|
workspaceUrl: string
|
|
kind: 'external' | 'internal' | 'byregion'
|
|
externalRegions?: string[]
|
|
}
|
|
): Promise<WorkspaceLoginInfo> {
|
|
const { workspaceUrl, kind, externalRegions = [] } = params
|
|
const { account: accountUuid, workspace: tokenWorkspaceUuid, extra } = decodeTokenVerbose(ctx, token ?? '')
|
|
const getKind = (region: string | undefined): EndpointKind => {
|
|
switch (kind) {
|
|
case 'external':
|
|
return EndpointKind.External
|
|
case 'internal':
|
|
return EndpointKind.Internal
|
|
case 'byregion':
|
|
return externalRegions.includes(region ?? '') ? EndpointKind.External : EndpointKind.Internal
|
|
default:
|
|
return EndpointKind.External
|
|
}
|
|
}
|
|
|
|
if (accountUuid === GUEST_ACCOUNT && extra?.guest === 'true') {
|
|
const workspace = await getWorkspaceByUrl(db, workspaceUrl)
|
|
if (workspace == null) {
|
|
ctx.error('Workspace not found in selectWorkspace', { workspaceUrl, kind, accountUuid, extra })
|
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUrl }))
|
|
}
|
|
|
|
// Guest mode select workspace
|
|
return {
|
|
account: accountUuid,
|
|
endpoint: getEndpoint(ctx, workspace.uuid, workspace.region, getKind(workspace.region)),
|
|
token,
|
|
workspace: workspace.uuid,
|
|
workspaceUrl: workspace.url,
|
|
workspaceDataId: workspace.dataId,
|
|
role: AccountRole.DocGuest
|
|
}
|
|
}
|
|
|
|
const account = await db.account.findOne({ uuid: accountUuid })
|
|
|
|
if (accountUuid !== systemAccountUuid && account == null) {
|
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {}))
|
|
}
|
|
|
|
let workspace: Workspace | null
|
|
if (workspaceUrl === '') {
|
|
// Find from token
|
|
workspace = await getWorkspaceById(db, tokenWorkspaceUuid)
|
|
} else {
|
|
workspace = await getWorkspaceByUrl(db, workspaceUrl)
|
|
}
|
|
|
|
if (workspace == null) {
|
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUrl }))
|
|
}
|
|
|
|
if (accountUuid === systemAccountUuid) {
|
|
return {
|
|
account: accountUuid,
|
|
token: generateToken(accountUuid, workspace.uuid, extra),
|
|
endpoint: getEndpoint(ctx, workspace.uuid, workspace.region, getKind(workspace.region)),
|
|
workspace: workspace.uuid,
|
|
workspaceUrl: workspace.url,
|
|
role: AccountRole.Owner
|
|
}
|
|
}
|
|
|
|
const role = await db.getWorkspaceRole(accountUuid, workspace.uuid)
|
|
|
|
if (role == null) {
|
|
ctx.error('Not a member of the workspace being selected', { workspaceUrl, accountUuid })
|
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
|
}
|
|
|
|
const wsStatus = await db.workspaceStatus.findOne({ workspaceUuid: workspace.uuid })
|
|
|
|
if (wsStatus != null) {
|
|
if (wsStatus.isDisabled && isActiveMode(wsStatus.mode)) {
|
|
ctx.error('Selecting a disabled workspace', { workspaceUrl, accountUuid })
|
|
|
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUrl }))
|
|
}
|
|
}
|
|
|
|
const person = await db.person.findOne({ uuid: accountUuid })
|
|
if (person == null) {
|
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.InternalServerError, {}))
|
|
}
|
|
|
|
return {
|
|
account: accountUuid,
|
|
token: generateToken(accountUuid, workspace.uuid, extra),
|
|
endpoint: getEndpoint(ctx, workspace.uuid, workspace.region, getKind(workspace.region)),
|
|
workspace: workspace.uuid,
|
|
workspaceUrl: workspace.url,
|
|
workspaceDataId: workspace.dataId,
|
|
role
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert workspace name to a URL-friendly string following these rules:
|
|
*
|
|
* 1. Converts all characters to lowercase
|
|
* 2. Only keeps alphanumeric characters (a-z, 0-9) and hyphens (-)
|
|
* 3. Cannot start with a number or hyphen
|
|
* 4. Cannot end with a hyphen
|
|
* 5. Removes all other special characters
|
|
*/
|
|
export function generateWorkspaceUrl (name: string): string {
|
|
const lowercaseName = name.toLowerCase()
|
|
let result = ''
|
|
let isFirst = true
|
|
|
|
for (const char of lowercaseName) {
|
|
const isValidChar = /[a-z0-9-]/.test(char)
|
|
const isNumber = /[0-9]/.test(char)
|
|
const isHyphen = char === '-'
|
|
|
|
if (isValidChar && (!isFirst || (!isNumber && !isHyphen))) {
|
|
result += char
|
|
isFirst = false
|
|
}
|
|
}
|
|
|
|
// Trim hyphens from the end
|
|
return result.replace(/-+$/, '')
|
|
}
|
|
|
|
// TODO: rework later to map exact codes for specific DBs
|
|
const DB_ERROR_CODES = {
|
|
UNIQUE_VIOLATION: [
|
|
'23505', // Postgres, CockroachDB
|
|
11000 // Mongo
|
|
]
|
|
}
|
|
|
|
interface CreateWorkspaceRecordResult {
|
|
workspaceUuid: WorkspaceUuid
|
|
workspaceUrl: string
|
|
}
|
|
|
|
export async function createWorkspaceRecord (
|
|
ctx: MeasureContext,
|
|
db: AccountDB,
|
|
branding: Branding | null,
|
|
workspaceName: string,
|
|
account: PersonUuid,
|
|
region: string = '',
|
|
initMode: WorkspaceMode = 'pending-creation'
|
|
): Promise<CreateWorkspaceRecordResult> {
|
|
const brandingKey = branding?.key ?? 'huly'
|
|
const regionInfo = getRegions().find((it) => it.region === region)
|
|
|
|
if (regionInfo === undefined) {
|
|
ctx.error('Region not found', { region })
|
|
|
|
throw new PlatformError(
|
|
new Status(Severity.ERROR, platform.status.InternalServerError, {
|
|
region
|
|
})
|
|
)
|
|
}
|
|
|
|
// The workspace url must be unique.
|
|
// This function is not concurrency safe, moreover multiple account services may be
|
|
// creating a workspace with the same base url at the same time so it's not possible
|
|
// to make it safe in the first place.
|
|
// But the uniqueness is guaranteed by the database rules and it will reject duplicate workspace urls.
|
|
// So we just need to handle the expected error and retry until we get a unique url.
|
|
let iteration = 0
|
|
let baseWorkspaceUrl = generateWorkspaceUrl(workspaceName)
|
|
let workspaceUrl = baseWorkspaceUrl
|
|
|
|
if (baseWorkspaceUrl === '') {
|
|
baseWorkspaceUrl = 'ws'
|
|
workspaceUrl = `ws-${generateId('-')}`
|
|
}
|
|
|
|
while (true) {
|
|
try {
|
|
const workspaceUuid = await db.createWorkspace(
|
|
{
|
|
name: workspaceName,
|
|
url: workspaceUrl,
|
|
branding: brandingKey,
|
|
createdBy: account,
|
|
billingAccount: account,
|
|
region
|
|
},
|
|
{
|
|
mode: initMode,
|
|
versionMajor: 0,
|
|
versionMinor: 0,
|
|
versionPatch: 0,
|
|
isDisabled: true
|
|
}
|
|
)
|
|
|
|
return {
|
|
workspaceUuid,
|
|
workspaceUrl
|
|
}
|
|
} catch (err: any) {
|
|
if (!DB_ERROR_CODES.UNIQUE_VIOLATION.includes(err.code)) {
|
|
throw err
|
|
}
|
|
}
|
|
|
|
workspaceUrl = `${baseWorkspaceUrl}-${generateId('-')}`
|
|
iteration++
|
|
// Safety check to prevent infinite loop. Should never happen if the code is alright.
|
|
if (iteration > 1000) {
|
|
ctx.error('Workspace record generation failed. Could not create a workspace record in 1000 attempts.', {
|
|
workspaceName
|
|
})
|
|
throw new PlatformError(
|
|
new Status(Severity.ERROR, platform.status.InternalServerError, { region, workspaceName, baseWorkspaceUrl })
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function checkInvite (ctx: MeasureContext, invite: WorkspaceInvite, email: string): Promise<WorkspaceUuid> {
|
|
if (invite.remainingUses === 0) {
|
|
ctx.error('Invite limit exceeded', { email, ...invite })
|
|
Analytics.handleError(new Error(`Invite limit exceeded ${email}`))
|
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
|
}
|
|
|
|
if (invite.expiresOn > 0 && invite.expiresOn < Date.now()) {
|
|
ctx.error('Invite link expired', { email, ...invite })
|
|
Analytics.handleError(new Error(`Invite link expired ${invite.id} ${email}`))
|
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.ExpiredLink, {}))
|
|
}
|
|
|
|
// TODO: consider not using RegExp with user input as some regexes might
|
|
// be slow or even cause catastrophic backtracking
|
|
if (
|
|
invite.emailPattern != null &&
|
|
invite.emailPattern.trim().length > 0 &&
|
|
!new RegExp(invite.emailPattern).test(email)
|
|
) {
|
|
ctx.error("Invite doesn't allow this email address", { email, ...invite })
|
|
Analytics.handleError(new Error(`Invite link email mask check failed ${invite.id} ${email} ${invite.emailPattern}`))
|
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
|
}
|
|
|
|
return invite.workspaceUuid
|
|
}
|
|
|
|
export async function sendEmailConfirmation (
|
|
ctx: MeasureContext,
|
|
branding: Branding | null,
|
|
account: PersonUuid,
|
|
email: string
|
|
): Promise<void> {
|
|
const sesURL = getMetadata(accountPlugin.metadata.SES_URL)
|
|
if (sesURL === undefined || sesURL === '') {
|
|
ctx.error('Please provide SES_URL to enable email confirmations.')
|
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.InternalServerError, {}))
|
|
}
|
|
|
|
const sesAuth = getMetadata(accountPlugin.metadata.SES_AUTH_TOKEN)
|
|
|
|
const front = branding?.front ?? getMetadata(accountPlugin.metadata.FrontURL)
|
|
if (front === undefined || front === '') {
|
|
ctx.error('Please provide front url via branding configuration or FRONT_URL variable')
|
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.InternalServerError, {}))
|
|
}
|
|
|
|
const token = generateToken(account, undefined, {
|
|
confirmEmail: email
|
|
})
|
|
|
|
const link = concatLink(front, `/login/confirm?id=${token}`)
|
|
|
|
const name = branding?.title ?? getMetadata(accountPlugin.metadata.ProductName)
|
|
const lang = branding?.language
|
|
const text = await translate(accountPlugin.string.ConfirmationText, { name, link }, lang)
|
|
const html = await translate(accountPlugin.string.ConfirmationHTML, { name, link }, lang)
|
|
const subject = await translate(accountPlugin.string.ConfirmationSubject, { name }, lang)
|
|
|
|
await fetch(concatLink(sesURL, '/send'), {
|
|
method: 'post',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...(sesAuth != null ? { Authorization: `Bearer ${sesAuth}` } : {})
|
|
},
|
|
body: JSON.stringify({
|
|
text,
|
|
html,
|
|
subject,
|
|
to: email
|
|
})
|
|
})
|
|
}
|
|
|
|
export async function confirmEmail (ctx: MeasureContext, db: AccountDB, account: string, email: string): Promise<void> {
|
|
const normalizedEmail = cleanEmail(email)
|
|
ctx.info('Confirming email', { account, email, normalizedEmail })
|
|
|
|
const emailSocialId = await getEmailSocialId(db, normalizedEmail)
|
|
|
|
if (emailSocialId == null) {
|
|
ctx.error('Email social id not found', { account, normalizedEmail })
|
|
throw new PlatformError(
|
|
new Status(Severity.ERROR, platform.status.SocialIdNotFound, {
|
|
socialId: normalizedEmail,
|
|
type: SocialIdType.EMAIL
|
|
})
|
|
)
|
|
}
|
|
|
|
if (emailSocialId.verifiedOn != null) {
|
|
throw new PlatformError(
|
|
new Status(Severity.ERROR, platform.status.SocialIdAlreadyConfirmed, {
|
|
socialId: normalizedEmail,
|
|
type: SocialIdType.EMAIL
|
|
})
|
|
)
|
|
}
|
|
|
|
await db.socialId.updateOne({ key: emailSocialId.key }, { verifiedOn: Date.now() })
|
|
}
|
|
|
|
export async function useInvite (db: AccountDB, inviteId: string): Promise<void> {
|
|
await db.invite.updateOne({ id: inviteId }, { $inc: { remainingUses: -1 } })
|
|
}
|
|
|
|
export async function getAccount (db: AccountDB, uuid: PersonUuid): Promise<Account | null> {
|
|
return await db.account.findOne({ uuid })
|
|
}
|
|
|
|
export async function getWorkspaceById (db: AccountDB, uuid: WorkspaceUuid): Promise<Workspace | null> {
|
|
return await db.workspace.findOne({ uuid })
|
|
}
|
|
|
|
export async function getWorkspaceInfoWithStatusById (
|
|
db: AccountDB,
|
|
uuid: WorkspaceUuid
|
|
): Promise<WorkspaceInfoWithStatus | null> {
|
|
const ws = await db.workspace.findOne({ uuid })
|
|
const status = await db.workspaceStatus.findOne({ workspaceUuid: uuid })
|
|
|
|
if (ws == null || status == null) {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
...ws,
|
|
status
|
|
}
|
|
}
|
|
|
|
export async function getWorkspacesInfoWithStatusByIds (
|
|
db: AccountDB,
|
|
uuids: WorkspaceUuid[]
|
|
): Promise<WorkspaceInfoWithStatus[]> {
|
|
const statuses = await db.workspaceStatus.find({ workspaceUuid: { $in: uuids } })
|
|
const statusesMap = statuses.reduce<Record<string, WorkspaceStatus>>((sm, s) => {
|
|
sm[s.workspaceUuid] = s
|
|
return sm
|
|
}, {})
|
|
const workspaces = await db.workspace.find({ uuid: { $in: uuids } })
|
|
|
|
return workspaces.map((it) => ({
|
|
...it,
|
|
status: statusesMap[it.uuid]
|
|
}))
|
|
}
|
|
|
|
export async function getWorkspaceByUrl (db: AccountDB, url: string): Promise<Workspace | null> {
|
|
return await db.workspace.findOne({ url })
|
|
}
|
|
|
|
export async function getWorkspaceInvite (db: AccountDB, id: string): Promise<WorkspaceInvite | null> {
|
|
const invite = await db.invite.findOne({ id })
|
|
|
|
if (invite != null) {
|
|
return invite
|
|
}
|
|
|
|
return await db.invite.findOne({ migratedFrom: id })
|
|
}
|
|
|
|
export async function getSocialIdByKey (db: AccountDB, socialKey: PersonId): Promise<SocialId | null> {
|
|
return await db.socialId.findOne({ key: socialKey })
|
|
}
|
|
|
|
export async function getEmailSocialId (db: AccountDB, email: string): Promise<SocialId | null> {
|
|
return await db.socialId.findOne({ type: SocialIdType.EMAIL, value: email })
|
|
}
|
|
|
|
export function getSesUrl (): { sesURL: string, sesAuth: string | undefined } {
|
|
const sesURL = getMetadata(accountPlugin.metadata.SES_URL)
|
|
|
|
if (sesURL === undefined || sesURL === '') {
|
|
throw new Error('Please provide email service url')
|
|
}
|
|
const sesAuth = getMetadata(accountPlugin.metadata.SES_AUTH_TOKEN)
|
|
|
|
return { sesURL, sesAuth }
|
|
}
|
|
|
|
export function getFrontUrl (branding: Branding | null): string {
|
|
const front = branding?.front ?? getMetadata(accountPlugin.metadata.FrontURL)
|
|
|
|
if (front === undefined || front === '') {
|
|
throw new Error('Please provide front url')
|
|
}
|
|
|
|
return front
|
|
}
|
|
|
|
export async function updateArchiveInfo (
|
|
ctx: MeasureContext,
|
|
db: AccountDB,
|
|
workspace: WorkspaceUuid,
|
|
value: boolean
|
|
): Promise<void> {
|
|
const workspaceInfo = await getWorkspaceById(db, workspace)
|
|
if (workspaceInfo === null) {
|
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid: workspace }))
|
|
}
|
|
|
|
await db.workspaceStatus.updateOne(
|
|
{ workspaceUuid: workspace },
|
|
{
|
|
mode: 'archived'
|
|
}
|
|
)
|
|
}
|
|
|
|
export async function doJoinByInvite (
|
|
ctx: MeasureContext,
|
|
db: AccountDB,
|
|
branding: Branding | null,
|
|
token: string,
|
|
account: PersonUuid,
|
|
workspace: Workspace,
|
|
invite: WorkspaceInvite
|
|
): Promise<WorkspaceLoginInfo> {
|
|
const role = await db.getWorkspaceRole(account, workspace.uuid)
|
|
|
|
// TODO: should we re-join kicked users? How are they marked as inactive?
|
|
if (role == null) {
|
|
await db.assignWorkspace(account, workspace.uuid, invite.role)
|
|
} else if (getRolePower(role) < getRolePower(invite.role)) {
|
|
await db.updateWorkspaceRole(account, workspace.uuid, invite.role)
|
|
}
|
|
|
|
const result = await selectWorkspace(ctx, db, branding, token, { workspaceUrl: workspace.url, kind: 'external' })
|
|
|
|
await useInvite(db, invite.id)
|
|
|
|
ctx.info('Successfully joined a workspace using invite', {
|
|
account,
|
|
workspaceUuid: workspace.uuid,
|
|
workspaceUrl: workspace.url,
|
|
workspaceName: workspace.name
|
|
})
|
|
|
|
return result
|
|
}
|
|
|
|
export async function loginOrSignUpWithProvider (
|
|
ctx: MeasureContext,
|
|
db: AccountDB,
|
|
branding: Branding | null,
|
|
email: string,
|
|
first: string,
|
|
last: string,
|
|
socialId: SocialKey,
|
|
signUpDisabled = false
|
|
): Promise<LoginInfo | null> {
|
|
const normalizedEmail = cleanEmail(email)
|
|
|
|
// Find if any of the target/email social ids exist
|
|
const targetSocialId = await db.socialId.findOne(socialId)
|
|
const emailSocialId =
|
|
normalizedEmail !== '' ? await db.socialId.findOne({ type: SocialIdType.EMAIL, value: normalizedEmail }) : undefined
|
|
let personUuid = targetSocialId?.personUuid ?? emailSocialId?.personUuid
|
|
|
|
if (personUuid == null) {
|
|
if (signUpDisabled) {
|
|
return null
|
|
}
|
|
|
|
personUuid = await db.person.insertOne({ firstName: first, lastName: last })
|
|
}
|
|
|
|
if (personUuid == null) {
|
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.InternalServerError, {}))
|
|
}
|
|
|
|
const person = await db.person.findOne({ uuid: personUuid })
|
|
|
|
if (person == null) {
|
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.InternalServerError, {}))
|
|
}
|
|
|
|
const account = await db.account.findOne({ uuid: personUuid })
|
|
|
|
if (account == null) {
|
|
if (signUpDisabled) {
|
|
return null
|
|
}
|
|
|
|
await createAccount(db, personUuid, true)
|
|
await db.person.updateOne({ uuid: personUuid }, { firstName: first, lastName: last })
|
|
}
|
|
|
|
// We should check and reset password if there's an account with password but no social ids have been
|
|
// confirmed yet
|
|
const confirmedSocialId = await db.socialId.findOne({ personUuid, verifiedOn: { $gt: 0 } })
|
|
|
|
if (confirmedSocialId == null) {
|
|
await db.resetPassword(personUuid)
|
|
}
|
|
|
|
// Create and/or confirm missing social ids
|
|
if (targetSocialId == null) {
|
|
await db.socialId.insertOne({ ...socialId, personUuid, verifiedOn: Date.now() })
|
|
} else if (targetSocialId.verifiedOn == null) {
|
|
await db.socialId.updateOne({ key: targetSocialId.key }, { verifiedOn: Date.now() })
|
|
}
|
|
|
|
if (emailSocialId == null) {
|
|
if (normalizedEmail !== '') {
|
|
await db.socialId.insertOne({
|
|
type: SocialIdType.EMAIL,
|
|
value: normalizedEmail,
|
|
personUuid,
|
|
verifiedOn: Date.now()
|
|
})
|
|
}
|
|
} else if (emailSocialId.verifiedOn == null) {
|
|
await db.socialId.updateOne({ key: emailSocialId.key }, { verifiedOn: Date.now() })
|
|
}
|
|
|
|
return {
|
|
account: personUuid,
|
|
socialId: buildSocialIdString(socialId),
|
|
name: getPersonName(person),
|
|
token: generateToken(personUuid)
|
|
}
|
|
}
|
|
|
|
export async function joinWithProvider (
|
|
ctx: MeasureContext,
|
|
db: AccountDB,
|
|
branding: Branding | null,
|
|
email: string,
|
|
first: string,
|
|
last: string,
|
|
inviteId: string,
|
|
socialId: SocialKey,
|
|
signUpDisabled = false
|
|
): Promise<WorkspaceLoginInfo | LoginInfo | null> {
|
|
const normalizedEmail = cleanEmail(email)
|
|
const invite = await getWorkspaceInvite(db, inviteId)
|
|
if (invite == null) {
|
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
|
}
|
|
|
|
const workspaceUuid = await checkInvite(ctx, invite, normalizedEmail)
|
|
const workspace = await getWorkspaceById(db, workspaceUuid)
|
|
|
|
if (workspace == null) {
|
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid }))
|
|
}
|
|
|
|
const loginInfo = await loginOrSignUpWithProvider(
|
|
ctx,
|
|
db,
|
|
branding,
|
|
normalizedEmail,
|
|
first,
|
|
last,
|
|
socialId,
|
|
signUpDisabled
|
|
)
|
|
|
|
if (loginInfo == null) {
|
|
return null
|
|
}
|
|
|
|
return await doJoinByInvite(
|
|
ctx,
|
|
db,
|
|
branding,
|
|
generateToken(loginInfo.account, workspaceUuid),
|
|
loginInfo.account,
|
|
workspace,
|
|
invite
|
|
)
|
|
}
|
|
|
|
export function flattenStatus (ws: WorkspaceInfoWithStatus): WorkspaceInfoWithStatusCore {
|
|
if (ws === undefined) {
|
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, {}))
|
|
}
|
|
|
|
const status = ws.status
|
|
const result: WorkspaceInfoWithStatusCore = { ...ws, ...status, createdOn: ws.createdOn as number }
|
|
delete (result as any).status
|
|
|
|
return result
|
|
}
|
|
|
|
export async function cleanExpiredOtp (db: AccountDB): Promise<void> {
|
|
await db.otp.deleteMany({ expiresOn: { $lte: Date.now() } })
|
|
}
|
|
|
|
export async function getWorkspaces (
|
|
db: AccountDB,
|
|
isDisabled?: boolean | null,
|
|
region?: string | null,
|
|
mode?: WorkspaceMode | null
|
|
): Promise<WorkspaceInfoWithStatus[]> {
|
|
const statuses = await db.workspaceStatus.find({})
|
|
const statusesMap = statuses.reduce<Record<string, WorkspaceStatus>>((sm, s) => {
|
|
sm[s.workspaceUuid] = s
|
|
return sm
|
|
}, {})
|
|
|
|
const workspaces = (await db.workspace.find(region != null ? { region } : {})).filter((it) => {
|
|
const status = statusesMap[it.uuid]
|
|
if (isDisabled === true) {
|
|
return status.isDisabled
|
|
} else if (isDisabled === false) {
|
|
return !status.isDisabled
|
|
}
|
|
|
|
if (mode != null) {
|
|
return status.mode === mode
|
|
}
|
|
|
|
return true
|
|
})
|
|
|
|
return workspaces.map((it) => ({
|
|
...it,
|
|
status: statusesMap[it.uuid]
|
|
}))
|
|
}
|
|
|
|
export function verifyAllowedServices (services: string[], extra: any): void {
|
|
if (!services.includes(extra?.service) && extra?.admin !== 'true') {
|
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
|
}
|
|
}
|
|
|
|
export function getPersonName (person: Person): string {
|
|
// Should we control the order by config?
|
|
return `${person.firstName} ${person.lastName}`
|
|
}
|
|
|
|
interface EmailInfo {
|
|
text: string
|
|
html: string
|
|
subject: string
|
|
to: string
|
|
}
|
|
|
|
export async function sendEmail (info: EmailInfo): Promise<void> {
|
|
const { text, html, subject, to } = info
|
|
const { sesURL, sesAuth } = getSesUrl()
|
|
await fetch(concatLink(sesURL, '/send'), {
|
|
method: 'post',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...(sesAuth != null ? { Authorization: `Bearer ${sesAuth}` } : {})
|
|
},
|
|
body: JSON.stringify({
|
|
text,
|
|
html,
|
|
subject,
|
|
to
|
|
})
|
|
})
|
|
}
|
|
|
|
export function sanitizeEmail (email: string): string {
|
|
if (email == null || typeof email !== 'string') return ''
|
|
const sanitizedEmail = email
|
|
.trim()
|
|
.replace(/[<>/\\@{}()[\]'"`]/g, '') // Remove special chars and quotes
|
|
.replace(/^(http|ssh|ftp|https|mailto|javascript|data|file):?\/?\/?\s*/i, '') // Remove potentially dangerous protocols
|
|
.slice(0, 40)
|
|
return sanitizedEmail
|
|
}
|
|
|
|
export async function getInviteEmail (
|
|
branding: Branding | null,
|
|
email: string,
|
|
inviteId: string,
|
|
workspace: Workspace,
|
|
expHours: number,
|
|
resend = false
|
|
): Promise<EmailInfo> {
|
|
const front = getFrontUrl(branding)
|
|
const link = concatLink(front, `/login/join?inviteId=${inviteId}`)
|
|
const ws = sanitizeEmail(workspace.name !== '' ? workspace.name : workspace.url)
|
|
const lang = branding?.language
|
|
|
|
return {
|
|
text: await translate(
|
|
resend ? accountPlugin.string.ResendInviteText : accountPlugin.string.InviteText,
|
|
{ link, ws, expHours },
|
|
lang
|
|
),
|
|
html: await translate(
|
|
resend ? accountPlugin.string.ResendInviteHTML : accountPlugin.string.InviteHTML,
|
|
{ link, ws, expHours },
|
|
lang
|
|
),
|
|
subject: await translate(
|
|
resend ? accountPlugin.string.ResendInviteSubject : accountPlugin.string.InviteSubject,
|
|
{ ws },
|
|
lang
|
|
),
|
|
to: email
|
|
}
|
|
}
|
|
|
|
export async function getWorkspaceRole (
|
|
db: AccountDB,
|
|
account: PersonUuid,
|
|
workspace: WorkspaceUuid
|
|
): Promise<AccountRole | null> {
|
|
if (account === systemAccountUuid) {
|
|
return AccountRole.Owner
|
|
}
|
|
|
|
return await db.getWorkspaceRole(account, workspace)
|
|
}
|