mirror of
https://github.com/hcengineering/platform.git
synced 2025-05-14 19:36:15 +00:00
uberf-9726: manage integrations in accounts (#8475)
Some checks are pending
CI / build (push) Waiting to run
CI / svelte-check (push) Blocked by required conditions
CI / formatting (push) Blocked by required conditions
CI / test (push) Blocked by required conditions
CI / uitest (push) Waiting to run
CI / uitest-pg (push) Waiting to run
CI / uitest-qms (push) Waiting to run
CI / uitest-workspaces (push) Waiting to run
CI / docker-build (push) Blocked by required conditions
CI / dist-build (push) Blocked by required conditions
Some checks are pending
CI / build (push) Waiting to run
CI / svelte-check (push) Blocked by required conditions
CI / formatting (push) Blocked by required conditions
CI / test (push) Blocked by required conditions
CI / uitest (push) Waiting to run
CI / uitest-pg (push) Waiting to run
CI / uitest-qms (push) Waiting to run
CI / uitest-workspaces (push) Waiting to run
CI / docker-build (push) Blocked by required conditions
CI / dist-build (push) Blocked by required conditions
This commit is contained in:
parent
099fb90c59
commit
9bf5234730
@ -38,7 +38,11 @@ import type {
|
||||
WorkspaceLoginInfo,
|
||||
RegionInfo,
|
||||
WorkspaceOperation,
|
||||
MailboxInfo
|
||||
MailboxInfo,
|
||||
Integration,
|
||||
IntegrationKey,
|
||||
IntegrationSecret,
|
||||
IntegrationSecretKey
|
||||
} from './types'
|
||||
|
||||
/** @public */
|
||||
@ -136,6 +140,25 @@ export interface AccountClient {
|
||||
lastName: string
|
||||
) => Promise<{ uuid: PersonUuid, socialId: PersonId }>
|
||||
addSocialIdToPerson: (person: PersonUuid, type: SocialIdType, value: string, confirmed: boolean) => Promise<PersonId>
|
||||
createIntegration: (integration: Integration) => Promise<void>
|
||||
updateIntegration: (integration: Integration) => Promise<void>
|
||||
deleteIntegration: (integrationKey: IntegrationKey) => Promise<void>
|
||||
getIntegration: (integrationKey: IntegrationKey) => Promise<Integration | null>
|
||||
listIntegrations: (filter: {
|
||||
socialId?: PersonId
|
||||
kind?: string
|
||||
workspaceUuid?: WorkspaceUuid | null
|
||||
}) => Promise<Integration[]>
|
||||
addIntegrationSecret: (integrationSecret: IntegrationSecret) => Promise<void>
|
||||
updateIntegrationSecret: (integrationSecret: IntegrationSecret) => Promise<void>
|
||||
deleteIntegrationSecret: (integrationSecretKey: IntegrationSecretKey) => Promise<void>
|
||||
getIntegrationSecret: (integrationSecretKey: IntegrationSecretKey) => Promise<IntegrationSecret | null>
|
||||
listIntegrationsSecrets: (filter: {
|
||||
socialId?: PersonId
|
||||
kind?: string
|
||||
workspaceUuid?: WorkspaceUuid | null
|
||||
key?: string
|
||||
}) => Promise<IntegrationSecret[]>
|
||||
|
||||
setCookie: () => Promise<void>
|
||||
deleteCookie: () => Promise<void>
|
||||
@ -721,6 +744,105 @@ class AccountClientImpl implements AccountClient {
|
||||
await this.rpc(request)
|
||||
}
|
||||
|
||||
async createIntegration (integration: Integration): Promise<void> {
|
||||
const request = {
|
||||
method: 'createIntegration' as const,
|
||||
params: integration
|
||||
}
|
||||
|
||||
await this.rpc(request)
|
||||
}
|
||||
|
||||
async updateIntegration (integration: Integration): Promise<void> {
|
||||
const request = {
|
||||
method: 'updateIntegration' as const,
|
||||
params: integration
|
||||
}
|
||||
|
||||
await this.rpc(request)
|
||||
}
|
||||
|
||||
async deleteIntegration (integrationKey: IntegrationKey): Promise<void> {
|
||||
const request = {
|
||||
method: 'deleteIntegration' as const,
|
||||
params: integrationKey
|
||||
}
|
||||
|
||||
await this.rpc(request)
|
||||
}
|
||||
|
||||
async getIntegration (integrationKey: IntegrationKey): Promise<Integration | null> {
|
||||
const request = {
|
||||
method: 'getIntegration' as const,
|
||||
params: integrationKey
|
||||
}
|
||||
|
||||
return await this.rpc(request)
|
||||
}
|
||||
|
||||
async listIntegrations (filter: {
|
||||
socialId?: PersonId
|
||||
kind?: string
|
||||
workspaceUuid?: WorkspaceUuid | null
|
||||
}): Promise<Integration[]> {
|
||||
const request = {
|
||||
method: 'listIntegrations' as const,
|
||||
params: filter
|
||||
}
|
||||
|
||||
return await this.rpc(request)
|
||||
}
|
||||
|
||||
async addIntegrationSecret (integrationSecret: IntegrationSecret): Promise<void> {
|
||||
const request = {
|
||||
method: 'addIntegrationSecret' as const,
|
||||
params: integrationSecret
|
||||
}
|
||||
|
||||
await this.rpc(request)
|
||||
}
|
||||
|
||||
async updateIntegrationSecret (integrationSecret: IntegrationSecret): Promise<void> {
|
||||
const request = {
|
||||
method: 'updateIntegrationSecret' as const,
|
||||
params: integrationSecret
|
||||
}
|
||||
|
||||
await this.rpc(request)
|
||||
}
|
||||
|
||||
async deleteIntegrationSecret (integrationSecretKey: IntegrationSecretKey): Promise<void> {
|
||||
const request = {
|
||||
method: 'deleteIntegrationSecret' as const,
|
||||
params: integrationSecretKey
|
||||
}
|
||||
|
||||
await this.rpc(request)
|
||||
}
|
||||
|
||||
async getIntegrationSecret (integrationSecretKey: IntegrationSecretKey): Promise<IntegrationSecret | null> {
|
||||
const request = {
|
||||
method: 'getIntegrationSecret' as const,
|
||||
params: integrationSecretKey
|
||||
}
|
||||
|
||||
return await this.rpc(request)
|
||||
}
|
||||
|
||||
async listIntegrationsSecrets (filter: {
|
||||
socialId?: PersonId
|
||||
kind?: string
|
||||
workspaceUuid?: WorkspaceUuid | null
|
||||
key?: string
|
||||
}): Promise<IntegrationSecret[]> {
|
||||
const request = {
|
||||
method: 'listIntegrationsSecrets' as const,
|
||||
params: filter
|
||||
}
|
||||
|
||||
return await this.rpc(request)
|
||||
}
|
||||
|
||||
async setCookie (): Promise<void> {
|
||||
const url = concatLink(this.url, '/cookie')
|
||||
const response = await fetch(url, { ...this.request, method: 'PUT' })
|
||||
|
@ -55,3 +55,22 @@ export interface MailboxInfo {
|
||||
aliases: string[]
|
||||
appPasswords: string[]
|
||||
}
|
||||
|
||||
export interface Integration {
|
||||
socialId: PersonId
|
||||
kind: string // Integration kind. E.g. 'github', 'mail', 'telegram-bot', 'telegram' etc.
|
||||
workspaceUuid?: WorkspaceUuid
|
||||
data?: Record<string, any>
|
||||
}
|
||||
|
||||
export type IntegrationKey = Omit<Integration, 'data'>
|
||||
|
||||
export interface IntegrationSecret {
|
||||
socialId: PersonId
|
||||
kind: string // Integration kind. E.g. 'github', 'mail', 'telegram-bot', 'telegram' etc.
|
||||
workspaceUuid?: WorkspaceUuid
|
||||
key: string // Key for the secret in the integration. Different secrets for the same integration must have different keys. Can be any string. E.g. '', 'user_app_1' etc.
|
||||
secret: string
|
||||
}
|
||||
|
||||
export type IntegrationSecretKey = Omit<IntegrationSecret, 'secret'>
|
||||
|
@ -13,7 +13,6 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
*/
|
||||
|
||||
import { Metadata, PluginLoader, PluginModule, Resources } from '.'
|
||||
|
||||
/**
|
||||
@ -152,9 +151,13 @@ export default plugin(platformId, {
|
||||
WorkspaceNotFound: '' as StatusCode<{ workspaceUuid?: string, workspaceName?: string, workspaceUrl?: string }>,
|
||||
WorkspaceArchived: '' as StatusCode<{ workspaceUuid: string }>,
|
||||
WorkspaceMigration: '' as StatusCode<{ workspaceUuid: string }>,
|
||||
SocialIdNotFound: '' as StatusCode<{ socialId: string, type: string }>,
|
||||
SocialIdNotFound: '' as StatusCode<{ value?: string, type?: string, _id?: string }>,
|
||||
SocialIdNotConfirmed: '' as StatusCode<{ socialId: string, type: string }>,
|
||||
SocialIdAlreadyConfirmed: '' as StatusCode<{ socialId: string, type: string }>,
|
||||
IntegrationAlreadyExists: '' as StatusCode,
|
||||
IntegrationNotFound: '' as StatusCode,
|
||||
IntegrationSecretAlreadyExists: '' as StatusCode,
|
||||
IntegrationSecretNotFound: '' as StatusCode,
|
||||
PersonNotFound: '' as StatusCode<{ person: string }>,
|
||||
InvalidPassword: '' as StatusCode<{ account: string }>,
|
||||
AccountAlreadyExists: '' as StatusCode,
|
||||
|
@ -13,13 +13,12 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { AccountRole, MeasureContext, PersonId, PersonUuid, SocialIdType, WorkspaceUuid } from '@hcengineering/core'
|
||||
import { AccountRole, MeasureContext, PersonUuid, WorkspaceUuid } from '@hcengineering/core'
|
||||
import platform, { PlatformError, Status, Severity, getMetadata } from '@hcengineering/platform'
|
||||
import { decodeTokenVerbose } from '@hcengineering/server-token'
|
||||
import * as utils from '../utils'
|
||||
|
||||
import { AccountDB } from '../types'
|
||||
import { createInvite, createInviteLink, sendInvite, resendInvite, addSocialIdToPerson } from '../operations'
|
||||
import { createInvite, createInviteLink, sendInvite, resendInvite } from '../operations'
|
||||
import { accountPlugin } from '../plugin'
|
||||
|
||||
// Mock platform
|
||||
@ -453,106 +452,4 @@ describe('invite operations', () => {
|
||||
expect(global.fetch).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('addSocialIdToPerson', () => {
|
||||
const mockCtx = {
|
||||
error: jest.fn()
|
||||
} as unknown as MeasureContext
|
||||
|
||||
const mockDb = {} as unknown as AccountDB
|
||||
const mockBranding = null
|
||||
const mockToken = 'test-token'
|
||||
|
||||
// Create spy only for this test suite
|
||||
const addSocialIdSpy = jest.spyOn(utils, 'addSocialId')
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
// Restore the original implementation
|
||||
addSocialIdSpy.mockRestore()
|
||||
})
|
||||
|
||||
test('should allow github service to add social id', async () => {
|
||||
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
|
||||
extra: { service: 'github' }
|
||||
})
|
||||
const newSocialId = 'new-social-id' as PersonId
|
||||
addSocialIdSpy.mockResolvedValue(newSocialId)
|
||||
|
||||
const params = {
|
||||
person: 'test-person' as PersonUuid,
|
||||
type: SocialIdType.GITHUB,
|
||||
value: 'test-value',
|
||||
confirmed: true
|
||||
}
|
||||
|
||||
const result = await addSocialIdToPerson(mockCtx, mockDb, mockBranding, mockToken, params)
|
||||
|
||||
expect(result).toBe(newSocialId)
|
||||
expect(addSocialIdSpy).toHaveBeenCalledWith(mockDb, params.person, params.type, params.value, params.confirmed)
|
||||
})
|
||||
|
||||
test('should allow admin to add social id', async () => {
|
||||
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
|
||||
extra: { admin: 'true' }
|
||||
})
|
||||
const newSocialId = 'new-social-id' as PersonId
|
||||
addSocialIdSpy.mockResolvedValue(newSocialId)
|
||||
|
||||
const params = {
|
||||
person: 'test-person' as PersonUuid,
|
||||
type: SocialIdType.GITHUB,
|
||||
value: 'test-value',
|
||||
confirmed: false
|
||||
}
|
||||
|
||||
const result = await addSocialIdToPerson(mockCtx, mockDb, mockBranding, mockToken, params)
|
||||
|
||||
expect(result).toBe(newSocialId)
|
||||
expect(addSocialIdSpy).toHaveBeenCalledWith(mockDb, params.person, params.type, params.value, params.confirmed)
|
||||
})
|
||||
|
||||
test('should throw error for unauthorized service', async () => {
|
||||
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
|
||||
extra: { service: 'other-service' }
|
||||
})
|
||||
|
||||
const params = {
|
||||
person: 'test-person' as PersonUuid,
|
||||
type: SocialIdType.GITHUB,
|
||||
value: 'test-value',
|
||||
confirmed: false
|
||||
}
|
||||
|
||||
await expect(addSocialIdToPerson(mockCtx, mockDb, mockBranding, mockToken, params)).rejects.toThrow(
|
||||
new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||
)
|
||||
|
||||
expect(addSocialIdSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should throw error for regular user', async () => {
|
||||
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
|
||||
account: 'test-account',
|
||||
workspace: 'test-workspace',
|
||||
extra: {}
|
||||
})
|
||||
|
||||
const params = {
|
||||
person: 'test-person' as PersonUuid,
|
||||
type: SocialIdType.GITHUB,
|
||||
value: 'test-value',
|
||||
confirmed: false
|
||||
}
|
||||
|
||||
await expect(addSocialIdToPerson(mockCtx, mockDb, mockBranding, mockToken, params)).rejects.toThrow(
|
||||
new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||
)
|
||||
|
||||
expect(addSocialIdSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -31,6 +31,8 @@ interface TestWorkspace {
|
||||
lastProcessingTime?: number
|
||||
}
|
||||
|
||||
const ns = 'global_account'
|
||||
|
||||
describe('PostgresDbCollection', () => {
|
||||
let mockClient: any
|
||||
let collection: PostgresDbCollection<TestWorkspace, 'uuid'>
|
||||
@ -40,7 +42,7 @@ describe('PostgresDbCollection', () => {
|
||||
unsafe: jest.fn().mockResolvedValue([]) // Default to empty array result
|
||||
}
|
||||
|
||||
collection = new PostgresDbCollection<TestWorkspace, 'uuid'>('workspace', mockClient as Sql, 'uuid')
|
||||
collection = new PostgresDbCollection<TestWorkspace, 'uuid'>('workspace', mockClient as Sql, 'uuid', ns)
|
||||
})
|
||||
|
||||
describe('getTableName', () => {
|
||||
@ -208,7 +210,7 @@ describe('AccountPostgresDbCollection', () => {
|
||||
unsafe: jest.fn().mockResolvedValue([])
|
||||
}
|
||||
|
||||
collection = new AccountPostgresDbCollection(mockClient as Sql)
|
||||
collection = new AccountPostgresDbCollection(mockClient as Sql, ns)
|
||||
})
|
||||
|
||||
describe('getTableName', () => {
|
||||
|
1009
server/account/src/__tests__/serviceOperations.test.ts
Normal file
1009
server/account/src/__tests__/serviceOperations.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -932,7 +932,7 @@ describe('account utils', () => {
|
||||
await expect(confirmEmail(mockCtx, mockDb, account, email)).rejects.toThrow(
|
||||
new PlatformError(
|
||||
new Status(Severity.ERROR, platform.status.SocialIdNotFound, {
|
||||
socialId: email,
|
||||
value: email,
|
||||
type: SocialIdType.EMAIL
|
||||
})
|
||||
)
|
||||
|
@ -51,7 +51,9 @@ import type {
|
||||
WorkspaceStatusData,
|
||||
Sort,
|
||||
Mailbox,
|
||||
MailboxSecret
|
||||
MailboxSecret,
|
||||
Integration,
|
||||
IntegrationSecret
|
||||
} from '../types'
|
||||
import { isShallowEqual } from '../utils'
|
||||
|
||||
@ -373,6 +375,8 @@ export class MongoAccountDB implements AccountDB {
|
||||
invite: MongoDbCollection<WorkspaceInvite, 'id'>
|
||||
mailbox: MongoDbCollection<Mailbox, 'mailbox'>
|
||||
mailboxSecret: MongoDbCollection<MailboxSecret>
|
||||
integration: MongoDbCollection<Integration>
|
||||
integrationSecret: MongoDbCollection<IntegrationSecret>
|
||||
|
||||
workspaceMembers: MongoDbCollection<WorkspaceMember>
|
||||
|
||||
@ -388,6 +392,8 @@ export class MongoAccountDB implements AccountDB {
|
||||
this.invite = new MongoDbCollection<WorkspaceInvite, 'id'>('invite', db, 'id')
|
||||
this.mailbox = new MongoDbCollection<Mailbox, 'mailbox'>('mailbox', db)
|
||||
this.mailboxSecret = new MongoDbCollection<MailboxSecret>('mailboxSecrets', db)
|
||||
this.integration = new MongoDbCollection<Integration>('integration', db)
|
||||
this.integrationSecret = new MongoDbCollection<IntegrationSecret>('integrationSecret', db)
|
||||
|
||||
this.workspaceMembers = new MongoDbCollection<WorkspaceMember>('workspaceMembers', db)
|
||||
}
|
||||
|
@ -41,7 +41,9 @@ import type {
|
||||
WorkspaceInfoWithStatus,
|
||||
Sort,
|
||||
Mailbox,
|
||||
MailboxSecret
|
||||
MailboxSecret,
|
||||
Integration,
|
||||
IntegrationSecret
|
||||
} from '../types'
|
||||
|
||||
function toSnakeCase (str: string): string {
|
||||
@ -86,13 +88,18 @@ function convertKeysToSnakeCase (obj: any): any {
|
||||
return obj
|
||||
}
|
||||
|
||||
function formatVar (idx: number, type?: string): string {
|
||||
return type != null ? `$${idx}::${type}` : `$${idx}`
|
||||
}
|
||||
|
||||
export class PostgresDbCollection<T extends Record<string, any>, K extends keyof T | undefined = undefined>
|
||||
implements DbCollection<T> {
|
||||
constructor (
|
||||
readonly name: string,
|
||||
readonly client: Sql,
|
||||
readonly idKey?: K,
|
||||
readonly ns: string = 'global_account'
|
||||
readonly ns?: string,
|
||||
readonly fieldTypes: Record<string, string> = {}
|
||||
) {}
|
||||
|
||||
getTableName (): string {
|
||||
@ -108,7 +115,14 @@ implements DbCollection<T> {
|
||||
}
|
||||
|
||||
protected buildWhereClause (query: Query<T>, lastRefIdx: number = 0): [string, any[]] {
|
||||
if (Object.keys(query).length === 0) {
|
||||
const filteredQuery = Object.entries(query).reduce<Query<T>>((acc, [key, value]) => {
|
||||
if (value !== undefined) {
|
||||
acc[key as keyof Query<T>] = value
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
if (Object.keys(filteredQuery).length === 0) {
|
||||
return ['', []]
|
||||
}
|
||||
|
||||
@ -116,9 +130,12 @@ implements DbCollection<T> {
|
||||
const values: any[] = []
|
||||
let currIdx: number = lastRefIdx
|
||||
|
||||
for (const key of Object.keys(query)) {
|
||||
const qKey = query[key]
|
||||
for (const key of Object.keys(filteredQuery)) {
|
||||
const qKey = filteredQuery[key]
|
||||
if (qKey === undefined) continue
|
||||
|
||||
const operator = qKey != null && typeof qKey === 'object' ? Object.keys(qKey)[0] : ''
|
||||
const castType = this.fieldTypes[key]
|
||||
const snakeKey = toSnakeCase(key)
|
||||
switch (operator) {
|
||||
case '$in': {
|
||||
@ -126,7 +143,7 @@ implements DbCollection<T> {
|
||||
const inVars: string[] = []
|
||||
for (const val of inVals) {
|
||||
currIdx++
|
||||
inVars.push(`$${currIdx}`)
|
||||
inVars.push(formatVar(currIdx, castType))
|
||||
values.push(val)
|
||||
}
|
||||
whereChunks.push(`"${snakeKey}" IN (${inVars.join(', ')})`)
|
||||
@ -134,38 +151,42 @@ implements DbCollection<T> {
|
||||
}
|
||||
case '$lt': {
|
||||
currIdx++
|
||||
whereChunks.push(`"${snakeKey}" < $${currIdx}`)
|
||||
whereChunks.push(`"${snakeKey}" < ${formatVar(currIdx, castType)}`)
|
||||
values.push(Object.values(qKey as object)[0])
|
||||
break
|
||||
}
|
||||
case '$lte': {
|
||||
currIdx++
|
||||
whereChunks.push(`"${snakeKey}" <= $${currIdx}`)
|
||||
whereChunks.push(`"${snakeKey}" <= ${formatVar(currIdx, castType)}`)
|
||||
values.push(Object.values(qKey as object)[0])
|
||||
break
|
||||
}
|
||||
case '$gt': {
|
||||
currIdx++
|
||||
whereChunks.push(`"${snakeKey}" > $${currIdx}`)
|
||||
whereChunks.push(`"${snakeKey}" > ${formatVar(currIdx, castType)}`)
|
||||
values.push(Object.values(qKey as object)[0])
|
||||
break
|
||||
}
|
||||
case '$gte': {
|
||||
currIdx++
|
||||
whereChunks.push(`"${snakeKey}" >= $${currIdx}`)
|
||||
whereChunks.push(`"${snakeKey}" >= ${formatVar(currIdx, castType)}`)
|
||||
values.push(Object.values(qKey as object)[0])
|
||||
break
|
||||
}
|
||||
case '$ne': {
|
||||
currIdx++
|
||||
whereChunks.push(`"${key}" != $${currIdx}`)
|
||||
whereChunks.push(`"${snakeKey}" != ${formatVar(currIdx, castType)}`)
|
||||
values.push(Object.values(qKey as object)[0])
|
||||
break
|
||||
}
|
||||
default: {
|
||||
currIdx++
|
||||
whereChunks.push(`"${snakeKey}" = $${currIdx}`)
|
||||
values.push(qKey)
|
||||
if (qKey !== null) {
|
||||
whereChunks.push(`"${snakeKey}" = ${formatVar(currIdx, castType)}`)
|
||||
values.push(qKey)
|
||||
} else {
|
||||
whereChunks.push(`"${snakeKey}" IS NULL`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -253,8 +274,9 @@ implements DbCollection<T> {
|
||||
}
|
||||
default: {
|
||||
const snakeKey = toSnakeCase(key)
|
||||
const castType = this.fieldTypes[key]
|
||||
currIdx++
|
||||
updateChunks.push(`"${snakeKey}" = $${currIdx}`)
|
||||
updateChunks.push(`"${snakeKey}" = ${formatVar(currIdx, castType)}`)
|
||||
values.push(ops[key])
|
||||
}
|
||||
}
|
||||
@ -297,8 +319,11 @@ export class AccountPostgresDbCollection
|
||||
implements DbCollection<Account> {
|
||||
private readonly passwordKeys = ['hash', 'salt']
|
||||
|
||||
constructor (readonly client: Sql) {
|
||||
super('account', client, 'uuid')
|
||||
constructor (
|
||||
readonly client: Sql,
|
||||
readonly ns?: string
|
||||
) {
|
||||
super('account', client, 'uuid', ns)
|
||||
}
|
||||
|
||||
getPasswordsTableName (): string {
|
||||
@ -381,21 +406,25 @@ export class PostgresAccountDB implements AccountDB {
|
||||
invite: PostgresDbCollection<WorkspaceInvite, 'id'>
|
||||
mailbox: PostgresDbCollection<Mailbox, 'mailbox'>
|
||||
mailboxSecret: PostgresDbCollection<MailboxSecret>
|
||||
integration: PostgresDbCollection<Integration>
|
||||
integrationSecret: PostgresDbCollection<IntegrationSecret>
|
||||
|
||||
constructor (
|
||||
readonly client: Sql,
|
||||
readonly ns: string = 'global_account'
|
||||
) {
|
||||
this.person = new PostgresDbCollection<Person, 'uuid'>('person', client, 'uuid')
|
||||
this.account = new AccountPostgresDbCollection(client)
|
||||
this.socialId = new PostgresDbCollection<SocialId, '_id'>('social_id', client, '_id')
|
||||
this.workspaceStatus = new PostgresDbCollection<WorkspaceStatus>('workspace_status', client)
|
||||
this.workspace = new PostgresDbCollection<Workspace, 'uuid'>('workspace', client, 'uuid')
|
||||
this.accountEvent = new PostgresDbCollection<AccountEvent>('account_events', client)
|
||||
this.otp = new PostgresDbCollection<OTP>('otp', client)
|
||||
this.invite = new PostgresDbCollection<WorkspaceInvite, 'id'>('invite', client, 'id')
|
||||
this.mailbox = new PostgresDbCollection<Mailbox, 'mailbox'>('mailbox', client)
|
||||
this.mailboxSecret = new PostgresDbCollection<MailboxSecret>('mailbox_secrets', client)
|
||||
this.person = new PostgresDbCollection<Person, 'uuid'>('person', client, 'uuid', ns)
|
||||
this.account = new AccountPostgresDbCollection(client, ns)
|
||||
this.socialId = new PostgresDbCollection<SocialId, '_id'>('social_id', client, '_id', ns)
|
||||
this.workspaceStatus = new PostgresDbCollection<WorkspaceStatus>('workspace_status', client, undefined, ns)
|
||||
this.workspace = new PostgresDbCollection<Workspace, 'uuid'>('workspace', client, 'uuid', ns)
|
||||
this.accountEvent = new PostgresDbCollection<AccountEvent>('account_events', client, undefined, ns)
|
||||
this.otp = new PostgresDbCollection<OTP>('otp', client, undefined, ns)
|
||||
this.invite = new PostgresDbCollection<WorkspaceInvite, 'id'>('invite', client, 'id', ns)
|
||||
this.mailbox = new PostgresDbCollection<Mailbox, 'mailbox'>('mailbox', client, undefined, ns)
|
||||
this.mailboxSecret = new PostgresDbCollection<MailboxSecret>('mailbox_secrets', client, undefined, ns)
|
||||
this.integration = new PostgresDbCollection<Integration>('integrations', client, undefined, ns)
|
||||
this.integrationSecret = new PostgresDbCollection<IntegrationSecret>('integration_secrets', client, undefined, ns)
|
||||
}
|
||||
|
||||
getWsMembersTableName (): string {
|
||||
@ -646,7 +675,8 @@ export class PostgresAccountDB implements AccountDB {
|
||||
this.getV3Migration(),
|
||||
this.getV4Migration(),
|
||||
this.getV4Migration1(),
|
||||
this.getV5Migration()
|
||||
this.getV5Migration(),
|
||||
this.getV6Migration()
|
||||
]
|
||||
}
|
||||
|
||||
@ -901,4 +931,36 @@ export class PostgresAccountDB implements AccountDB {
|
||||
`
|
||||
]
|
||||
}
|
||||
|
||||
private getV6Migration (): [string, string] {
|
||||
return [
|
||||
'account_db_v6_add_social_id_integrations',
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS ${this.ns}.integrations (
|
||||
social_id INT8 NOT NULL,
|
||||
kind STRING NOT NULL,
|
||||
workspace_uuid UUID,
|
||||
_def_ws_uuid UUID NOT NULL GENERATED ALWAYS AS (COALESCE(workspace_uuid, '00000000-0000-0000-0000-000000000000')) STORED NOT VISIBLE,
|
||||
data JSONB,
|
||||
CONSTRAINT integrations_pk PRIMARY KEY (social_id, kind, _def_ws_uuid),
|
||||
INDEX integrations_kind_idx (kind),
|
||||
CONSTRAINT integrations_social_id_fk FOREIGN KEY (social_id) REFERENCES ${this.ns}.social_id(_id),
|
||||
CONSTRAINT integrations_workspace_fk FOREIGN KEY (workspace_uuid) REFERENCES ${this.ns}.workspace(uuid)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ${this.ns}.integration_secrets (
|
||||
social_id INT8 NOT NULL,
|
||||
kind STRING NOT NULL,
|
||||
workspace_uuid UUID,
|
||||
_def_ws_uuid UUID NOT NULL GENERATED ALWAYS AS (COALESCE(workspace_uuid, '00000000-0000-0000-0000-000000000000')) STORED NOT VISIBLE,
|
||||
key STRING,
|
||||
secret STRING NOT NULL,
|
||||
CONSTRAINT integration_secrets_pk PRIMARY KEY (social_id, kind, _def_ws_uuid, key),
|
||||
CONSTRAINT integration_secrets_integrations_fk FOREIGN KEY (social_id, kind, _def_ws_uuid)
|
||||
REFERENCES ${this.ns}.integrations(social_id, kind, _def_ws_uuid)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
`
|
||||
]
|
||||
}
|
||||
}
|
||||
|
@ -17,31 +17,20 @@ import {
|
||||
AccountRole,
|
||||
buildSocialIdString,
|
||||
concatLink,
|
||||
Data,
|
||||
isActiveMode,
|
||||
isWorkspaceCreating,
|
||||
MeasureContext,
|
||||
SocialIdType,
|
||||
systemAccountUuid,
|
||||
Version,
|
||||
type BackupStatus,
|
||||
type Branding,
|
||||
type Person,
|
||||
type PersonId,
|
||||
type PersonInfo,
|
||||
type PersonUuid,
|
||||
type WorkspaceMemberInfo,
|
||||
type WorkspaceMode,
|
||||
type WorkspaceUuid
|
||||
} from '@hcengineering/core'
|
||||
import platform, {
|
||||
getMetadata,
|
||||
PlatformError,
|
||||
Severity,
|
||||
Status,
|
||||
translate,
|
||||
unknownError
|
||||
} from '@hcengineering/platform'
|
||||
import platform, { getMetadata, PlatformError, Severity, Status, translate } from '@hcengineering/platform'
|
||||
import { decodeTokenVerbose, generateToken } from '@hcengineering/server-token'
|
||||
|
||||
import { isAdminEmail } from './admin'
|
||||
@ -55,13 +44,9 @@ import type {
|
||||
OtpInfo,
|
||||
RegionInfo,
|
||||
SocialId,
|
||||
Workspace,
|
||||
WorkspaceEvent,
|
||||
WorkspaceInfoWithStatus,
|
||||
WorkspaceInviteInfo,
|
||||
WorkspaceLoginInfo,
|
||||
WorkspaceOperation,
|
||||
WorkspaceStatus
|
||||
WorkspaceLoginInfo
|
||||
} from './types'
|
||||
import {
|
||||
checkInvite,
|
||||
@ -80,12 +65,9 @@ import {
|
||||
getRegions,
|
||||
getRolePower,
|
||||
getMailUrl,
|
||||
getSocialIdByKey,
|
||||
getWorkspaceById,
|
||||
getWorkspaceInfoWithStatusById,
|
||||
getWorkspaceInvite,
|
||||
getWorkspaces,
|
||||
getWorkspacesInfoWithStatusByIds,
|
||||
GUEST_ACCOUNT,
|
||||
isOtpValid,
|
||||
selectWorkspace,
|
||||
@ -103,11 +85,14 @@ import {
|
||||
isEmail,
|
||||
generatePassword,
|
||||
addSocialId,
|
||||
releaseSocialId
|
||||
releaseSocialId,
|
||||
updateWorkspaceRole
|
||||
} from './utils'
|
||||
import { type AccountServiceMethods, getServiceMethods } from './serviceOperations'
|
||||
|
||||
// Move to config?
|
||||
const processingTimeoutMs = 30 * 1000
|
||||
// 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
|
||||
@ -947,7 +932,7 @@ export async function requestPasswordReset (
|
||||
if (emailSocialId == null) {
|
||||
ctx.error('Email social id not found', { email, normalizedEmail })
|
||||
throw new PlatformError(
|
||||
new Status(Severity.ERROR, platform.status.SocialIdNotFound, { socialId: email, type: SocialIdType.EMAIL })
|
||||
new Status(Severity.ERROR, platform.status.SocialIdNotFound, { value: email, type: SocialIdType.EMAIL })
|
||||
)
|
||||
}
|
||||
|
||||
@ -1020,7 +1005,7 @@ export async function restorePassword (
|
||||
if (emailSocialId == null) {
|
||||
ctx.error('Email social id not found', { email })
|
||||
throw new PlatformError(
|
||||
new Status(Severity.ERROR, platform.status.SocialIdNotFound, { socialId: email, type: SocialIdType.EMAIL })
|
||||
new Status(Severity.ERROR, platform.status.SocialIdNotFound, { value: email, type: SocialIdType.EMAIL })
|
||||
)
|
||||
}
|
||||
|
||||
@ -1245,128 +1230,6 @@ export async function getWorkspaceInfo (
|
||||
return workspace
|
||||
}
|
||||
|
||||
export async function listWorkspaces (
|
||||
ctx: MeasureContext,
|
||||
db: AccountDB,
|
||||
branding: Branding | null,
|
||||
token: string,
|
||||
params: {
|
||||
region?: string | null
|
||||
mode?: WorkspaceMode | null
|
||||
}
|
||||
): Promise<WorkspaceInfoWithStatus[]> {
|
||||
const { region, mode } = params
|
||||
const { extra } = decodeTokenVerbose(ctx, token)
|
||||
|
||||
if (!['tool', 'backup', 'admin'].includes(extra?.service) && extra?.admin !== 'true') {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||
}
|
||||
|
||||
return await getWorkspaces(db, false, region, mode)
|
||||
}
|
||||
|
||||
export async function performWorkspaceOperation (
|
||||
ctx: MeasureContext,
|
||||
db: AccountDB,
|
||||
branding: Branding | null,
|
||||
token: string,
|
||||
parameters: {
|
||||
workspaceId: WorkspaceUuid | WorkspaceUuid[]
|
||||
event: 'archive' | 'migrate-to' | 'unarchive' | 'delete' | 'reset-attempts'
|
||||
params: any[]
|
||||
}
|
||||
): Promise<boolean> {
|
||||
const { workspaceId, event, params } = parameters
|
||||
const { extra, workspace } = decodeTokenVerbose(ctx, token)
|
||||
|
||||
if (extra?.admin !== 'true') {
|
||||
if (event !== 'unarchive' || workspaceId !== workspace) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||
}
|
||||
}
|
||||
|
||||
const workspaceUuids = Array.isArray(workspaceId) ? workspaceId : [workspaceId]
|
||||
|
||||
const workspaces = await getWorkspacesInfoWithStatusByIds(db, workspaceUuids)
|
||||
if (workspaces.length === 0) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, {}))
|
||||
}
|
||||
|
||||
let ops = 0
|
||||
for (const workspace of workspaces) {
|
||||
const update: Partial<WorkspaceStatus> = {}
|
||||
switch (event) {
|
||||
case 'reset-attempts':
|
||||
update.processingAttempts = 0
|
||||
update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step
|
||||
break
|
||||
case 'delete':
|
||||
if (workspace.status.mode !== 'active') {
|
||||
throw new PlatformError(unknownError('Delete allowed only for active workspaces'))
|
||||
}
|
||||
|
||||
update.mode = 'pending-deletion'
|
||||
update.processingAttempts = 0
|
||||
update.processingProgress = 0
|
||||
update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step
|
||||
break
|
||||
case 'archive':
|
||||
if (!isActiveMode(workspace.status.mode)) {
|
||||
throw new PlatformError(unknownError('Archiving allowed only for active workspaces'))
|
||||
}
|
||||
|
||||
update.mode = 'archiving-pending-backup'
|
||||
update.processingAttempts = 0
|
||||
update.processingProgress = 0
|
||||
update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step
|
||||
break
|
||||
case 'unarchive':
|
||||
if (event === 'unarchive') {
|
||||
if (workspace.status.mode !== 'archived') {
|
||||
throw new PlatformError(unknownError('Unarchive allowed only for archived workspaces'))
|
||||
}
|
||||
}
|
||||
|
||||
update.mode = 'pending-restore'
|
||||
update.processingAttempts = 0
|
||||
update.processingProgress = 0
|
||||
update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step
|
||||
break
|
||||
case 'migrate-to': {
|
||||
if (!isActiveMode(workspace.status.mode)) {
|
||||
return false
|
||||
}
|
||||
if (params.length !== 1 && params[0] == null) {
|
||||
throw new PlatformError(unknownError('Invalid region passed to migrate operation'))
|
||||
}
|
||||
const regions = getRegions()
|
||||
if (regions.find((it) => it.region === params[0]) === undefined) {
|
||||
throw new PlatformError(unknownError('Invalid region passed to migrate operation'))
|
||||
}
|
||||
if ((workspace.region ?? '') === params[0]) {
|
||||
throw new PlatformError(unknownError('Invalid region passed to migrate operation'))
|
||||
}
|
||||
|
||||
update.mode = 'migration-pending-backup'
|
||||
// NOTE: will only work for Mongo accounts
|
||||
update.targetRegion = params[0]
|
||||
update.processingAttempts = 0
|
||||
update.processingProgress = 0
|
||||
update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
if (Object.keys(update).length !== 0) {
|
||||
await db.workspaceStatus.updateOne({ workspaceUuid: workspace.uuid }, update)
|
||||
ops++
|
||||
}
|
||||
}
|
||||
return ops > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the token and returns the decoded account information.
|
||||
*/
|
||||
@ -1630,357 +1493,6 @@ export async function getWorkspaceMembers (
|
||||
return await db.getWorkspaceMembers(workspace)
|
||||
}
|
||||
|
||||
export async function updateWorkspaceRoleBySocialKey (
|
||||
ctx: MeasureContext,
|
||||
db: AccountDB,
|
||||
branding: Branding | null,
|
||||
token: string,
|
||||
params: {
|
||||
socialKey: string
|
||||
targetRole: AccountRole
|
||||
}
|
||||
): Promise<void> {
|
||||
const { socialKey, targetRole } = params
|
||||
const { extra } = decodeTokenVerbose(ctx, token)
|
||||
|
||||
if (!['workspace', 'tool'].includes(extra?.service)) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||
}
|
||||
|
||||
const socialId = await getSocialIdByKey(db, socialKey.toLowerCase() as PersonId)
|
||||
if (socialId == null) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {}))
|
||||
}
|
||||
|
||||
await updateWorkspaceRole(ctx, db, branding, token, { targetAccount: socialId.personUuid, targetRole })
|
||||
}
|
||||
|
||||
export async function updateWorkspaceRole (
|
||||
ctx: MeasureContext,
|
||||
db: AccountDB,
|
||||
branding: Branding | null,
|
||||
token: string,
|
||||
params: {
|
||||
targetAccount: PersonUuid
|
||||
targetRole: AccountRole
|
||||
}
|
||||
): Promise<void> {
|
||||
const { targetAccount, targetRole } = params
|
||||
|
||||
const { account, workspace } = decodeTokenVerbose(ctx, token)
|
||||
|
||||
if (workspace === null) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid: workspace }))
|
||||
}
|
||||
|
||||
const accRole = account === systemAccountUuid ? AccountRole.Owner : await db.getWorkspaceRole(account, workspace)
|
||||
|
||||
if (
|
||||
accRole == null ||
|
||||
getRolePower(accRole) < getRolePower(AccountRole.Maintainer) ||
|
||||
getRolePower(accRole) < getRolePower(targetRole)
|
||||
) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||
}
|
||||
|
||||
const currentRole = await db.getWorkspaceRole(targetAccount, workspace)
|
||||
|
||||
if (currentRole == null || getRolePower(accRole) < getRolePower(currentRole)) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||
}
|
||||
|
||||
if (currentRole === targetRole) return
|
||||
|
||||
if (currentRole === AccountRole.Owner) {
|
||||
// Check if there are other owners
|
||||
const owners = (await db.getWorkspaceMembers(workspace)).filter((m) => m.role === AccountRole.Owner)
|
||||
if (owners.length === 1) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||
}
|
||||
}
|
||||
|
||||
await db.updateWorkspaceRole(targetAccount, workspace, targetRole)
|
||||
}
|
||||
|
||||
/* =================================== */
|
||||
/* ===WORKSPACE SERVICE OPERATIONS==== */
|
||||
/* =================================== */
|
||||
|
||||
/**
|
||||
* Retrieves one workspace for which there are things to process.
|
||||
*
|
||||
* Workspace is provided for 30seconds. This timeout is reset
|
||||
* on every progress update.
|
||||
* If no progress is reported for the workspace during this time,
|
||||
* it will become available again to be processed by another executor.
|
||||
*/
|
||||
export async function getPendingWorkspace (
|
||||
ctx: MeasureContext,
|
||||
db: AccountDB,
|
||||
branding: Branding | null,
|
||||
token: string,
|
||||
params: {
|
||||
region: string
|
||||
version: Data<Version>
|
||||
operation: WorkspaceOperation
|
||||
}
|
||||
): Promise<WorkspaceInfoWithStatus | undefined> {
|
||||
const { region, version, operation } = params
|
||||
const { extra } = decodeTokenVerbose(ctx, token)
|
||||
if (extra?.service !== 'workspace') {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||
}
|
||||
|
||||
const wsLivenessDays = getMetadata(accountPlugin.metadata.WsLivenessDays)
|
||||
const wsLivenessMs = wsLivenessDays !== undefined ? wsLivenessDays * 24 * 60 * 60 * 1000 : undefined
|
||||
|
||||
const result = await db.getPendingWorkspace(region, version, operation, processingTimeoutMs, wsLivenessMs)
|
||||
|
||||
if (result != null) {
|
||||
ctx.info('getPendingWorkspace', {
|
||||
workspaceId: result.uuid,
|
||||
workspaceName: result.name,
|
||||
mode: result.status.mode,
|
||||
operation,
|
||||
region,
|
||||
major: result.status.versionMajor,
|
||||
minor: result.status.versionMinor,
|
||||
patch: result.status.versionPatch,
|
||||
requestedVersion: version
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export async function updateWorkspaceInfo (
|
||||
ctx: MeasureContext,
|
||||
db: AccountDB,
|
||||
branding: Branding | null,
|
||||
token: string,
|
||||
params: {
|
||||
workspaceUuid: WorkspaceUuid
|
||||
event: WorkspaceEvent
|
||||
version: Data<Version> // A worker version
|
||||
progress: number
|
||||
message?: string
|
||||
}
|
||||
): Promise<void> {
|
||||
const { workspaceUuid, event, version, message } = params
|
||||
let progress = params.progress
|
||||
|
||||
const { extra } = decodeTokenVerbose(ctx, token)
|
||||
if (!['workspace', 'tool'].includes(extra?.service)) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||
}
|
||||
|
||||
const workspace = await getWorkspaceInfoWithStatusById(db, workspaceUuid)
|
||||
if (workspace === null) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid }))
|
||||
}
|
||||
progress = Math.round(progress)
|
||||
|
||||
const update: Partial<WorkspaceStatus> = {}
|
||||
const wsUpdate: Partial<Workspace> = {}
|
||||
switch (event) {
|
||||
case 'create-started':
|
||||
update.mode = 'creating'
|
||||
if (workspace.status.mode !== 'creating') {
|
||||
update.processingAttempts = 0
|
||||
}
|
||||
update.processingProgress = progress
|
||||
break
|
||||
case 'upgrade-started':
|
||||
if (workspace.status.mode !== 'upgrading') {
|
||||
update.processingAttempts = 0
|
||||
}
|
||||
update.mode = 'upgrading'
|
||||
update.processingProgress = progress
|
||||
break
|
||||
case 'create-done':
|
||||
ctx.info('Updating workspace info on create-done', { workspaceUuid, event, version, progress })
|
||||
update.mode = 'active'
|
||||
update.isDisabled = false
|
||||
update.versionMajor = version.major
|
||||
update.versionMinor = version.minor
|
||||
update.versionPatch = version.patch
|
||||
update.processingProgress = progress
|
||||
break
|
||||
case 'upgrade-done':
|
||||
ctx.info('Updating workspace info on upgrade-done', { workspaceUuid, event, version, progress })
|
||||
update.mode = 'active'
|
||||
update.versionMajor = version.major
|
||||
update.versionMinor = version.minor
|
||||
update.versionPatch = version.patch
|
||||
update.processingProgress = progress
|
||||
break
|
||||
case 'progress':
|
||||
update.processingProgress = progress
|
||||
break
|
||||
case 'migrate-backup-started':
|
||||
update.mode = 'migration-backup'
|
||||
update.processingProgress = progress
|
||||
break
|
||||
case 'migrate-backup-done':
|
||||
update.mode = 'migration-pending-clean'
|
||||
update.processingProgress = progress
|
||||
update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step
|
||||
break
|
||||
case 'migrate-clean-started':
|
||||
update.mode = 'migration-clean'
|
||||
update.processingAttempts = 0
|
||||
update.processingProgress = progress
|
||||
break
|
||||
case 'migrate-clean-done':
|
||||
wsUpdate.region = workspace.status.targetRegion ?? ''
|
||||
update.mode = 'pending-restore'
|
||||
update.processingProgress = progress
|
||||
update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step
|
||||
break
|
||||
case 'restore-started':
|
||||
update.mode = 'restoring'
|
||||
update.processingAttempts = 0
|
||||
update.processingProgress = progress
|
||||
break
|
||||
case 'restore-done':
|
||||
update.mode = 'active'
|
||||
update.processingProgress = 100
|
||||
update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step
|
||||
break
|
||||
case 'archiving-backup-started':
|
||||
update.mode = 'archiving-backup'
|
||||
update.processingAttempts = 0
|
||||
update.processingProgress = progress
|
||||
break
|
||||
case 'archiving-backup-done':
|
||||
update.mode = 'archiving-pending-clean'
|
||||
update.processingProgress = progress
|
||||
update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step
|
||||
break
|
||||
case 'archiving-clean-started':
|
||||
update.mode = 'archiving-clean'
|
||||
update.processingAttempts = 0
|
||||
update.processingProgress = progress
|
||||
break
|
||||
case 'archiving-clean-done':
|
||||
update.mode = 'archived'
|
||||
update.processingProgress = 100
|
||||
break
|
||||
case 'ping':
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
if (message != null) {
|
||||
update.processingMessage = message
|
||||
}
|
||||
|
||||
await db.workspaceStatus.updateOne(
|
||||
{ workspaceUuid: workspace.uuid },
|
||||
{
|
||||
lastProcessingTime: Date.now(), // Some operations override it.
|
||||
...update
|
||||
}
|
||||
)
|
||||
|
||||
if (Object.keys(wsUpdate).length !== 0) {
|
||||
await db.workspace.updateOne({ uuid: workspace.uuid }, wsUpdate)
|
||||
}
|
||||
}
|
||||
|
||||
export async function workerHandshake (
|
||||
ctx: MeasureContext,
|
||||
db: AccountDB,
|
||||
branding: Branding | null,
|
||||
token: string,
|
||||
params: {
|
||||
region: string
|
||||
version: Data<Version>
|
||||
operation: WorkspaceOperation
|
||||
}
|
||||
): Promise<void> {
|
||||
const { region, version, operation } = params
|
||||
const { extra } = decodeTokenVerbose(ctx, token)
|
||||
if (extra?.service !== 'workspace') {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||
}
|
||||
|
||||
ctx.info('Worker handshake happened', { region, version, operation })
|
||||
// Nothing else to do now but keeping to have track of workers in logs
|
||||
}
|
||||
|
||||
export async function updateBackupInfo (
|
||||
ctx: MeasureContext,
|
||||
db: AccountDB,
|
||||
branding: Branding | null,
|
||||
token: string,
|
||||
params: { backupInfo: BackupStatus }
|
||||
): Promise<void> {
|
||||
const { backupInfo } = params
|
||||
const { extra, workspace } = decodeTokenVerbose(ctx, token)
|
||||
if (extra?.service !== 'backup') {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||
}
|
||||
|
||||
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 },
|
||||
{
|
||||
backupInfo,
|
||||
lastProcessingTime: Date.now()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export async function assignWorkspace (
|
||||
ctx: MeasureContext,
|
||||
db: AccountDB,
|
||||
branding: Branding | null,
|
||||
token: string,
|
||||
params: {
|
||||
email: string
|
||||
workspaceUuid: WorkspaceUuid
|
||||
role: AccountRole
|
||||
}
|
||||
): Promise<void> {
|
||||
const { email, workspaceUuid, role } = params
|
||||
const { extra } = decodeTokenVerbose(ctx, token)
|
||||
if (!['aibot', 'tool', 'workspace'].includes(extra?.service)) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
if (account == null) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {}))
|
||||
}
|
||||
|
||||
const workspace = await getWorkspaceById(db, workspaceUuid)
|
||||
|
||||
if (workspace == null) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid }))
|
||||
}
|
||||
|
||||
const currentRole = await db.getWorkspaceRole(account.uuid, workspaceUuid)
|
||||
|
||||
if (currentRole == null) {
|
||||
await db.assignWorkspace(account.uuid, workspaceUuid, role)
|
||||
} else if (getRolePower(currentRole) < getRolePower(role)) {
|
||||
await db.updateWorkspaceRole(account.uuid, workspaceUuid, role)
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensurePerson (
|
||||
ctx: MeasureContext,
|
||||
db: AccountDB,
|
||||
@ -2021,7 +1533,14 @@ export async function ensurePerson (
|
||||
return { uuid: personUuid, socialId: newSocialId }
|
||||
}
|
||||
|
||||
async function getMailboxOptions (): Promise<MailboxOptions> {
|
||||
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'),
|
||||
@ -2045,7 +1564,7 @@ async function createMailbox (
|
||||
const normalizedName = cleanEmail(name)
|
||||
const normalizedDomain = cleanEmail(domain)
|
||||
const mailbox = normalizedName + '@' + normalizedDomain
|
||||
const opts = await getMailboxOptions()
|
||||
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' }))
|
||||
@ -2107,22 +1626,8 @@ async function deleteMailbox (
|
||||
ctx.info('Mailbox deleted', { mailbox, account })
|
||||
}
|
||||
|
||||
export async function addSocialIdToPerson (
|
||||
ctx: MeasureContext,
|
||||
db: AccountDB,
|
||||
branding: Branding | null,
|
||||
token: string,
|
||||
params: { person: PersonUuid, type: SocialIdType, value: string, confirmed: boolean }
|
||||
): Promise<PersonId> {
|
||||
const { person, type, value, confirmed } = params
|
||||
const { extra } = decodeTokenVerbose(ctx, token)
|
||||
|
||||
verifyAllowedServices(['github'], extra)
|
||||
|
||||
return await addSocialId(db, person, type, value, confirmed)
|
||||
}
|
||||
|
||||
export type AccountMethods =
|
||||
| AccountServiceMethods
|
||||
| 'login'
|
||||
| 'loginOtp'
|
||||
| 'signUp'
|
||||
@ -2150,14 +1655,8 @@ export type AccountMethods =
|
||||
| 'getUserWorkspaces'
|
||||
| 'getWorkspaceInfo'
|
||||
| 'getWorkspacesInfo'
|
||||
| 'listWorkspaces'
|
||||
| 'getLoginInfoByToken'
|
||||
| 'getSocialIds'
|
||||
| 'getPendingWorkspace'
|
||||
| 'updateWorkspaceInfo'
|
||||
| 'workerHandshake'
|
||||
| 'updateBackupInfo'
|
||||
| 'assignWorkspace'
|
||||
| 'getPerson'
|
||||
| 'getPersonInfo'
|
||||
| 'getWorkspaceMembers'
|
||||
@ -2165,14 +1664,11 @@ export type AccountMethods =
|
||||
| 'findPersonBySocialKey'
|
||||
| 'findPersonBySocialId'
|
||||
| 'findSocialIdBySocialKey'
|
||||
| 'performWorkspaceOperation'
|
||||
| 'updateWorkspaceRoleBySocialKey'
|
||||
| 'ensurePerson'
|
||||
| 'getMailboxOptions'
|
||||
| 'createMailbox'
|
||||
| 'getMailboxes'
|
||||
| 'deleteMailbox'
|
||||
| 'addSocialIdToPerson'
|
||||
|
||||
/**
|
||||
* @public
|
||||
@ -2207,6 +1703,7 @@ export function getMethods (hasSignUp: boolean = true): Partial<Record<AccountMe
|
||||
createMailbox: wrap(createMailbox),
|
||||
getMailboxes: wrap(getMailboxes),
|
||||
deleteMailbox: wrap(deleteMailbox),
|
||||
ensurePerson: wrap(ensurePerson),
|
||||
|
||||
/* READ OPERATIONS */
|
||||
getRegionInfo: wrap(getRegionInfo),
|
||||
@ -2224,18 +1721,10 @@ export function getMethods (hasSignUp: boolean = true): Partial<Record<AccountMe
|
||||
getMailboxOptions: wrap(getMailboxOptions),
|
||||
|
||||
/* SERVICE METHODS */
|
||||
getPendingWorkspace: wrap(getPendingWorkspace),
|
||||
updateWorkspaceInfo: wrap(updateWorkspaceInfo),
|
||||
workerHandshake: wrap(workerHandshake),
|
||||
updateBackupInfo: wrap(updateBackupInfo),
|
||||
assignWorkspace: wrap(assignWorkspace),
|
||||
listWorkspaces: wrap(listWorkspaces),
|
||||
performWorkspaceOperation: wrap(performWorkspaceOperation),
|
||||
updateWorkspaceRoleBySocialKey: wrap(updateWorkspaceRoleBySocialKey),
|
||||
ensurePerson: wrap(ensurePerson),
|
||||
addSocialIdToPerson: wrap(addSocialIdToPerson)
|
||||
...getServiceMethods()
|
||||
}
|
||||
}
|
||||
|
||||
export * from './plugin'
|
||||
export * from './serviceOperations'
|
||||
export default accountPlugin
|
||||
|
805
server/account/src/serviceOperations.ts
Normal file
805
server/account/src/serviceOperations.ts
Normal file
@ -0,0 +1,805 @@
|
||||
//
|
||||
// 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 {
|
||||
AccountRole,
|
||||
Data,
|
||||
isActiveMode,
|
||||
MeasureContext,
|
||||
SocialIdType,
|
||||
Version,
|
||||
WorkspaceMode,
|
||||
type BackupStatus,
|
||||
type Branding,
|
||||
type PersonId,
|
||||
type PersonUuid,
|
||||
type WorkspaceUuid
|
||||
} from '@hcengineering/core'
|
||||
import platform, { getMetadata, PlatformError, Severity, Status, unknownError } from '@hcengineering/platform'
|
||||
import { decodeTokenVerbose } from '@hcengineering/server-token'
|
||||
|
||||
import { accountPlugin } from './plugin'
|
||||
import type {
|
||||
AccountDB,
|
||||
AccountMethodHandler,
|
||||
Integration,
|
||||
IntegrationKey,
|
||||
IntegrationSecret,
|
||||
IntegrationSecretKey,
|
||||
Workspace,
|
||||
WorkspaceEvent,
|
||||
WorkspaceInfoWithStatus,
|
||||
WorkspaceOperation,
|
||||
WorkspaceStatus
|
||||
} from './types'
|
||||
import {
|
||||
cleanEmail,
|
||||
getAccount,
|
||||
getEmailSocialId,
|
||||
getRegions,
|
||||
getRolePower,
|
||||
getSocialIdByKey,
|
||||
getWorkspaceById,
|
||||
getWorkspaceInfoWithStatusById,
|
||||
getWorkspacesInfoWithStatusByIds,
|
||||
verifyAllowedServices,
|
||||
wrap,
|
||||
addSocialId,
|
||||
getWorkspaces,
|
||||
updateWorkspaceRole
|
||||
} 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 !!!
|
||||
|
||||
// Move to config?
|
||||
const processingTimeoutMs = 30 * 1000
|
||||
|
||||
export async function listWorkspaces (
|
||||
ctx: MeasureContext,
|
||||
db: AccountDB,
|
||||
branding: Branding | null,
|
||||
token: string,
|
||||
params: {
|
||||
region?: string | null
|
||||
mode?: WorkspaceMode | null
|
||||
}
|
||||
): Promise<WorkspaceInfoWithStatus[]> {
|
||||
const { region, mode } = params
|
||||
const { extra } = decodeTokenVerbose(ctx, token)
|
||||
|
||||
if (!['tool', 'backup', 'admin'].includes(extra?.service) && extra?.admin !== 'true') {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||
}
|
||||
|
||||
return await getWorkspaces(db, false, region, mode)
|
||||
}
|
||||
|
||||
export async function performWorkspaceOperation (
|
||||
ctx: MeasureContext,
|
||||
db: AccountDB,
|
||||
branding: Branding | null,
|
||||
token: string,
|
||||
parameters: {
|
||||
workspaceId: WorkspaceUuid | WorkspaceUuid[]
|
||||
event: 'archive' | 'migrate-to' | 'unarchive' | 'delete' | 'reset-attempts'
|
||||
params: any[]
|
||||
}
|
||||
): Promise<boolean> {
|
||||
const { workspaceId, event, params } = parameters
|
||||
const { extra, workspace } = decodeTokenVerbose(ctx, token)
|
||||
|
||||
if (extra?.admin !== 'true') {
|
||||
if (event !== 'unarchive' || workspaceId !== workspace) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||
}
|
||||
}
|
||||
|
||||
const workspaceUuids = Array.isArray(workspaceId) ? workspaceId : [workspaceId]
|
||||
|
||||
const workspaces = await getWorkspacesInfoWithStatusByIds(db, workspaceUuids)
|
||||
if (workspaces.length === 0) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, {}))
|
||||
}
|
||||
|
||||
let ops = 0
|
||||
for (const workspace of workspaces) {
|
||||
const update: Partial<WorkspaceStatus> = {}
|
||||
switch (event) {
|
||||
case 'reset-attempts':
|
||||
update.processingAttempts = 0
|
||||
update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step
|
||||
break
|
||||
case 'delete':
|
||||
if (workspace.status.mode !== 'active') {
|
||||
throw new PlatformError(unknownError('Delete allowed only for active workspaces'))
|
||||
}
|
||||
|
||||
update.mode = 'pending-deletion'
|
||||
update.processingAttempts = 0
|
||||
update.processingProgress = 0
|
||||
update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step
|
||||
break
|
||||
case 'archive':
|
||||
if (!isActiveMode(workspace.status.mode)) {
|
||||
throw new PlatformError(unknownError('Archiving allowed only for active workspaces'))
|
||||
}
|
||||
|
||||
update.mode = 'archiving-pending-backup'
|
||||
update.processingAttempts = 0
|
||||
update.processingProgress = 0
|
||||
update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step
|
||||
break
|
||||
case 'unarchive':
|
||||
if (event === 'unarchive') {
|
||||
if (workspace.status.mode !== 'archived') {
|
||||
throw new PlatformError(unknownError('Unarchive allowed only for archived workspaces'))
|
||||
}
|
||||
}
|
||||
|
||||
update.mode = 'pending-restore'
|
||||
update.processingAttempts = 0
|
||||
update.processingProgress = 0
|
||||
update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step
|
||||
break
|
||||
case 'migrate-to': {
|
||||
if (!isActiveMode(workspace.status.mode)) {
|
||||
return false
|
||||
}
|
||||
if (params.length !== 1 && params[0] == null) {
|
||||
throw new PlatformError(unknownError('Invalid region passed to migrate operation'))
|
||||
}
|
||||
const regions = getRegions()
|
||||
if (regions.find((it) => it.region === params[0]) === undefined) {
|
||||
throw new PlatformError(unknownError('Invalid region passed to migrate operation'))
|
||||
}
|
||||
if ((workspace.region ?? '') === params[0]) {
|
||||
throw new PlatformError(unknownError('Invalid region passed to migrate operation'))
|
||||
}
|
||||
|
||||
update.mode = 'migration-pending-backup'
|
||||
// NOTE: will only work for Mongo accounts
|
||||
update.targetRegion = params[0]
|
||||
update.processingAttempts = 0
|
||||
update.processingProgress = 0
|
||||
update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
if (Object.keys(update).length !== 0) {
|
||||
await db.workspaceStatus.updateOne({ workspaceUuid: workspace.uuid }, update)
|
||||
ops++
|
||||
}
|
||||
}
|
||||
return ops > 0
|
||||
}
|
||||
|
||||
export async function updateWorkspaceRoleBySocialKey (
|
||||
ctx: MeasureContext,
|
||||
db: AccountDB,
|
||||
branding: Branding | null,
|
||||
token: string,
|
||||
params: {
|
||||
socialKey: string
|
||||
targetRole: AccountRole
|
||||
}
|
||||
): Promise<void> {
|
||||
const { socialKey, targetRole } = params
|
||||
const { extra } = decodeTokenVerbose(ctx, token)
|
||||
|
||||
if (!['workspace', 'tool'].includes(extra?.service)) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||
}
|
||||
|
||||
const socialId = await getSocialIdByKey(db, socialKey.toLowerCase() as PersonId)
|
||||
if (socialId == null) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {}))
|
||||
}
|
||||
|
||||
await updateWorkspaceRole(ctx, db, branding, token, { targetAccount: socialId.personUuid, targetRole })
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves one workspace for which there are things to process.
|
||||
*
|
||||
* Workspace is provided for 30seconds. This timeout is reset
|
||||
* on every progress update.
|
||||
* If no progress is reported for the workspace during this time,
|
||||
* it will become available again to be processed by another executor.
|
||||
*/
|
||||
export async function getPendingWorkspace (
|
||||
ctx: MeasureContext,
|
||||
db: AccountDB,
|
||||
branding: Branding | null,
|
||||
token: string,
|
||||
params: {
|
||||
region: string
|
||||
version: Data<Version>
|
||||
operation: WorkspaceOperation
|
||||
}
|
||||
): Promise<WorkspaceInfoWithStatus | undefined> {
|
||||
const { region, version, operation } = params
|
||||
const { extra } = decodeTokenVerbose(ctx, token)
|
||||
if (extra?.service !== 'workspace') {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||
}
|
||||
|
||||
const wsLivenessDays = getMetadata(accountPlugin.metadata.WsLivenessDays)
|
||||
const wsLivenessMs = wsLivenessDays !== undefined ? wsLivenessDays * 24 * 60 * 60 * 1000 : undefined
|
||||
|
||||
const result = await db.getPendingWorkspace(region, version, operation, processingTimeoutMs, wsLivenessMs)
|
||||
|
||||
if (result != null) {
|
||||
ctx.info('getPendingWorkspace', {
|
||||
workspaceId: result.uuid,
|
||||
workspaceName: result.name,
|
||||
mode: result.status.mode,
|
||||
operation,
|
||||
region,
|
||||
major: result.status.versionMajor,
|
||||
minor: result.status.versionMinor,
|
||||
patch: result.status.versionPatch,
|
||||
requestedVersion: version
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export async function updateWorkspaceInfo (
|
||||
ctx: MeasureContext,
|
||||
db: AccountDB,
|
||||
branding: Branding | null,
|
||||
token: string,
|
||||
params: {
|
||||
workspaceUuid: WorkspaceUuid
|
||||
event: WorkspaceEvent
|
||||
version: Data<Version> // A worker version
|
||||
progress: number
|
||||
message?: string
|
||||
}
|
||||
): Promise<void> {
|
||||
const { workspaceUuid, event, version, message } = params
|
||||
let progress = params.progress
|
||||
|
||||
const { extra } = decodeTokenVerbose(ctx, token)
|
||||
if (!['workspace', 'tool'].includes(extra?.service)) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||
}
|
||||
|
||||
const workspace = await getWorkspaceInfoWithStatusById(db, workspaceUuid)
|
||||
if (workspace === null) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid }))
|
||||
}
|
||||
progress = Math.round(progress)
|
||||
|
||||
const update: Partial<WorkspaceStatus> = {}
|
||||
const wsUpdate: Partial<Workspace> = {}
|
||||
switch (event) {
|
||||
case 'create-started':
|
||||
update.mode = 'creating'
|
||||
if (workspace.status.mode !== 'creating') {
|
||||
update.processingAttempts = 0
|
||||
}
|
||||
update.processingProgress = progress
|
||||
break
|
||||
case 'upgrade-started':
|
||||
if (workspace.status.mode !== 'upgrading') {
|
||||
update.processingAttempts = 0
|
||||
}
|
||||
update.mode = 'upgrading'
|
||||
update.processingProgress = progress
|
||||
break
|
||||
case 'create-done':
|
||||
ctx.info('Updating workspace info on create-done', { workspaceUuid, event, version, progress })
|
||||
update.mode = 'active'
|
||||
update.isDisabled = false
|
||||
update.versionMajor = version.major
|
||||
update.versionMinor = version.minor
|
||||
update.versionPatch = version.patch
|
||||
update.processingProgress = progress
|
||||
break
|
||||
case 'upgrade-done':
|
||||
ctx.info('Updating workspace info on upgrade-done', { workspaceUuid, event, version, progress })
|
||||
update.mode = 'active'
|
||||
update.versionMajor = version.major
|
||||
update.versionMinor = version.minor
|
||||
update.versionPatch = version.patch
|
||||
update.processingProgress = progress
|
||||
break
|
||||
case 'progress':
|
||||
update.processingProgress = progress
|
||||
break
|
||||
case 'migrate-backup-started':
|
||||
update.mode = 'migration-backup'
|
||||
update.processingProgress = progress
|
||||
break
|
||||
case 'migrate-backup-done':
|
||||
update.mode = 'migration-pending-clean'
|
||||
update.processingProgress = progress
|
||||
update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step
|
||||
break
|
||||
case 'migrate-clean-started':
|
||||
update.mode = 'migration-clean'
|
||||
update.processingAttempts = 0
|
||||
update.processingProgress = progress
|
||||
break
|
||||
case 'migrate-clean-done':
|
||||
wsUpdate.region = workspace.status.targetRegion ?? ''
|
||||
update.mode = 'pending-restore'
|
||||
update.processingProgress = progress
|
||||
update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step
|
||||
break
|
||||
case 'restore-started':
|
||||
update.mode = 'restoring'
|
||||
update.processingAttempts = 0
|
||||
update.processingProgress = progress
|
||||
break
|
||||
case 'restore-done':
|
||||
update.mode = 'active'
|
||||
update.processingProgress = 100
|
||||
update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step
|
||||
break
|
||||
case 'archiving-backup-started':
|
||||
update.mode = 'archiving-backup'
|
||||
update.processingAttempts = 0
|
||||
update.processingProgress = progress
|
||||
break
|
||||
case 'archiving-backup-done':
|
||||
update.mode = 'archiving-pending-clean'
|
||||
update.processingProgress = progress
|
||||
update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step
|
||||
break
|
||||
case 'archiving-clean-started':
|
||||
update.mode = 'archiving-clean'
|
||||
update.processingAttempts = 0
|
||||
update.processingProgress = progress
|
||||
break
|
||||
case 'archiving-clean-done':
|
||||
update.mode = 'archived'
|
||||
update.processingProgress = 100
|
||||
break
|
||||
case 'ping':
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
if (message != null) {
|
||||
update.processingMessage = message
|
||||
}
|
||||
|
||||
await db.workspaceStatus.updateOne(
|
||||
{ workspaceUuid: workspace.uuid },
|
||||
{
|
||||
lastProcessingTime: Date.now(), // Some operations override it.
|
||||
...update
|
||||
}
|
||||
)
|
||||
|
||||
if (Object.keys(wsUpdate).length !== 0) {
|
||||
await db.workspace.updateOne({ uuid: workspace.uuid }, wsUpdate)
|
||||
}
|
||||
}
|
||||
|
||||
export async function workerHandshake (
|
||||
ctx: MeasureContext,
|
||||
db: AccountDB,
|
||||
branding: Branding | null,
|
||||
token: string,
|
||||
params: {
|
||||
region: string
|
||||
version: Data<Version>
|
||||
operation: WorkspaceOperation
|
||||
}
|
||||
): Promise<void> {
|
||||
const { region, version, operation } = params
|
||||
const { extra } = decodeTokenVerbose(ctx, token)
|
||||
if (extra?.service !== 'workspace') {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||
}
|
||||
|
||||
ctx.info('Worker handshake happened', { region, version, operation })
|
||||
// Nothing else to do now but keeping to have track of workers in logs
|
||||
}
|
||||
|
||||
export async function updateBackupInfo (
|
||||
ctx: MeasureContext,
|
||||
db: AccountDB,
|
||||
branding: Branding | null,
|
||||
token: string,
|
||||
params: { backupInfo: BackupStatus }
|
||||
): Promise<void> {
|
||||
const { backupInfo } = params
|
||||
const { extra, workspace } = decodeTokenVerbose(ctx, token)
|
||||
if (extra?.service !== 'backup') {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||
}
|
||||
|
||||
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 },
|
||||
{
|
||||
backupInfo,
|
||||
lastProcessingTime: Date.now()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export async function assignWorkspace (
|
||||
ctx: MeasureContext,
|
||||
db: AccountDB,
|
||||
branding: Branding | null,
|
||||
token: string,
|
||||
params: {
|
||||
email: string
|
||||
workspaceUuid: WorkspaceUuid
|
||||
role: AccountRole
|
||||
}
|
||||
): Promise<void> {
|
||||
const { email, workspaceUuid, role } = params
|
||||
const { extra } = decodeTokenVerbose(ctx, token)
|
||||
if (!['aibot', 'tool', 'workspace'].includes(extra?.service)) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
if (account == null) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {}))
|
||||
}
|
||||
|
||||
const workspace = await getWorkspaceById(db, workspaceUuid)
|
||||
|
||||
if (workspace == null) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid }))
|
||||
}
|
||||
|
||||
const currentRole = await db.getWorkspaceRole(account.uuid, workspaceUuid)
|
||||
|
||||
if (currentRole == null) {
|
||||
await db.assignWorkspace(account.uuid, workspaceUuid, role)
|
||||
} else if (getRolePower(currentRole) < getRolePower(role)) {
|
||||
await db.updateWorkspaceRole(account.uuid, workspaceUuid, role)
|
||||
}
|
||||
}
|
||||
|
||||
export async function addSocialIdToPerson (
|
||||
ctx: MeasureContext,
|
||||
db: AccountDB,
|
||||
branding: Branding | null,
|
||||
token: string,
|
||||
params: { person: PersonUuid, type: SocialIdType, value: string, confirmed: boolean }
|
||||
): Promise<PersonId> {
|
||||
const { person, type, value, confirmed } = params
|
||||
const { extra } = decodeTokenVerbose(ctx, token)
|
||||
|
||||
verifyAllowedServices(['github'], extra)
|
||||
|
||||
return await addSocialId(db, person, type, value, confirmed)
|
||||
}
|
||||
|
||||
// Move to config?
|
||||
const integrationServices = ['github', 'telegram-bot', 'telegram', 'mailbox']
|
||||
|
||||
export async function createIntegration (
|
||||
ctx: MeasureContext,
|
||||
db: AccountDB,
|
||||
branding: Branding | null,
|
||||
token: string,
|
||||
params: Integration
|
||||
): Promise<void> {
|
||||
const { extra } = decodeTokenVerbose(ctx, token)
|
||||
verifyAllowedServices(integrationServices, extra)
|
||||
const { socialId, kind, workspaceUuid, data } = params
|
||||
|
||||
if (kind == null || socialId == null) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
|
||||
}
|
||||
|
||||
const existingSocialId = await db.socialId.findOne({ _id: socialId })
|
||||
if (existingSocialId == null) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.SocialIdNotFound, { _id: socialId }))
|
||||
}
|
||||
|
||||
if (workspaceUuid != null) {
|
||||
const workspace = await getWorkspaceById(db, workspaceUuid)
|
||||
if (workspace == null) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid }))
|
||||
}
|
||||
}
|
||||
|
||||
const existing = await db.integration.findOne({ socialId, kind, workspaceUuid })
|
||||
if (existing != null) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationAlreadyExists, {}))
|
||||
}
|
||||
|
||||
await db.integration.insertOne({ socialId, kind, workspaceUuid, data })
|
||||
}
|
||||
|
||||
export async function updateIntegration (
|
||||
ctx: MeasureContext,
|
||||
db: AccountDB,
|
||||
branding: Branding | null,
|
||||
token: string,
|
||||
params: Integration
|
||||
): Promise<void> {
|
||||
const { extra } = decodeTokenVerbose(ctx, token)
|
||||
verifyAllowedServices(integrationServices, extra)
|
||||
const { socialId, kind, workspaceUuid, data } = params
|
||||
|
||||
const existing = await db.integration.findOne({ socialId, kind, workspaceUuid })
|
||||
if (existing == null) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationNotFound, {}))
|
||||
}
|
||||
|
||||
await db.integration.updateOne({ socialId, kind, workspaceUuid }, { data })
|
||||
}
|
||||
|
||||
export async function deleteIntegration (
|
||||
ctx: MeasureContext,
|
||||
db: AccountDB,
|
||||
branding: Branding | null,
|
||||
token: string,
|
||||
params: IntegrationKey
|
||||
): Promise<void> {
|
||||
const { extra } = decodeTokenVerbose(ctx, token)
|
||||
verifyAllowedServices(integrationServices, extra)
|
||||
const { socialId, kind, workspaceUuid } = params
|
||||
|
||||
const existing = await db.integration.findOne({ socialId, kind, workspaceUuid })
|
||||
if (existing == null) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationNotFound, {}))
|
||||
}
|
||||
|
||||
await db.integration.deleteMany({ socialId, kind, workspaceUuid })
|
||||
}
|
||||
|
||||
export async function listIntegrations (
|
||||
ctx: MeasureContext,
|
||||
db: AccountDB,
|
||||
branding: Branding | null,
|
||||
token: string,
|
||||
params: {
|
||||
socialId?: PersonId
|
||||
kind?: string
|
||||
workspaceUuid?: WorkspaceUuid | null
|
||||
}
|
||||
): Promise<Integration[]> {
|
||||
const { account, extra } = decodeTokenVerbose(ctx, token)
|
||||
const isAllowedService = verifyAllowedServices(integrationServices, extra, false)
|
||||
const { socialId, kind, workspaceUuid } = params
|
||||
let socialIds: PersonId[] | undefined
|
||||
|
||||
if (isAllowedService) {
|
||||
socialIds = socialId != null ? [socialId] : undefined
|
||||
} else {
|
||||
const socialIdObjs = await db.socialId.find({ personUuid: account, verifiedOn: { $gt: 0 } })
|
||||
|
||||
if (socialIdObjs.length === 0) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||
}
|
||||
|
||||
const allowedSocialIds = socialIdObjs.map((it) => it._id)
|
||||
|
||||
if (socialId !== undefined) {
|
||||
if (!allowedSocialIds.includes(socialId)) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||
}
|
||||
|
||||
socialIds = [socialId]
|
||||
} else {
|
||||
socialIds = allowedSocialIds
|
||||
}
|
||||
}
|
||||
|
||||
return await db.integration.find({
|
||||
...(socialIds != null ? { socialId: { $in: socialIds } } : {}),
|
||||
kind,
|
||||
workspaceUuid
|
||||
})
|
||||
}
|
||||
|
||||
export async function getIntegration (
|
||||
ctx: MeasureContext,
|
||||
db: AccountDB,
|
||||
branding: Branding | null,
|
||||
token: string,
|
||||
params: IntegrationKey
|
||||
): Promise<Integration | null> {
|
||||
const { account, extra } = decodeTokenVerbose(ctx, token)
|
||||
const isAllowedService = verifyAllowedServices(integrationServices, extra, false)
|
||||
const { socialId, kind, workspaceUuid } = params
|
||||
|
||||
if (!isAllowedService) {
|
||||
const existingSocialId = await db.socialId.findOne({ _id: socialId, personUuid: account, verifiedOn: { $gt: 0 } })
|
||||
|
||||
if (existingSocialId == null) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||
}
|
||||
}
|
||||
|
||||
return await db.integration.findOne({ socialId, kind, workspaceUuid })
|
||||
}
|
||||
|
||||
export async function addIntegrationSecret (
|
||||
ctx: MeasureContext,
|
||||
db: AccountDB,
|
||||
branding: Branding | null,
|
||||
token: string,
|
||||
params: IntegrationSecret
|
||||
): Promise<void> {
|
||||
const { extra } = decodeTokenVerbose(ctx, token)
|
||||
verifyAllowedServices(integrationServices, extra)
|
||||
const { socialId, kind, workspaceUuid, key, secret } = params
|
||||
|
||||
if (kind == null || socialId == null || key == null) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
|
||||
}
|
||||
|
||||
const integrationKey: IntegrationKey = { socialId, kind, workspaceUuid }
|
||||
const secretKey: IntegrationSecretKey = { ...integrationKey, key }
|
||||
|
||||
const existingIntegration = await db.integration.findOne(integrationKey)
|
||||
if (existingIntegration == null) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationNotFound, {}))
|
||||
}
|
||||
|
||||
const existingSecret = await db.integrationSecret.findOne(secretKey)
|
||||
if (existingSecret != null) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationSecretAlreadyExists, {}))
|
||||
}
|
||||
|
||||
await db.integrationSecret.insertOne({ ...secretKey, secret })
|
||||
}
|
||||
|
||||
export async function updateIntegrationSecret (
|
||||
ctx: MeasureContext,
|
||||
db: AccountDB,
|
||||
branding: Branding | null,
|
||||
token: string,
|
||||
params: IntegrationSecret
|
||||
): Promise<void> {
|
||||
const { extra } = decodeTokenVerbose(ctx, token)
|
||||
verifyAllowedServices(integrationServices, extra)
|
||||
const { socialId, kind, workspaceUuid, key, secret } = params
|
||||
const secretKey: IntegrationSecretKey = { socialId, kind, workspaceUuid, key }
|
||||
|
||||
const existingSecret = await db.integrationSecret.findOne(secretKey)
|
||||
if (existingSecret == null) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationSecretNotFound, {}))
|
||||
}
|
||||
|
||||
await db.integrationSecret.updateOne(secretKey, { secret })
|
||||
}
|
||||
|
||||
export async function deleteIntegrationSecret (
|
||||
ctx: MeasureContext,
|
||||
db: AccountDB,
|
||||
branding: Branding | null,
|
||||
token: string,
|
||||
params: IntegrationSecretKey
|
||||
): Promise<void> {
|
||||
const { extra } = decodeTokenVerbose(ctx, token)
|
||||
verifyAllowedServices(integrationServices, extra)
|
||||
const { socialId, kind, workspaceUuid, key } = params
|
||||
const secretKey: IntegrationSecretKey = { socialId, kind, workspaceUuid, key }
|
||||
|
||||
const existingSecret = await db.integrationSecret.findOne(secretKey)
|
||||
if (existingSecret == null) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationSecretNotFound, {}))
|
||||
}
|
||||
|
||||
await db.integrationSecret.deleteMany(secretKey)
|
||||
}
|
||||
|
||||
export async function getIntegrationSecret (
|
||||
ctx: MeasureContext,
|
||||
db: AccountDB,
|
||||
branding: Branding | null,
|
||||
token: string,
|
||||
params: IntegrationSecretKey
|
||||
): Promise<IntegrationSecret | null> {
|
||||
const { extra } = decodeTokenVerbose(ctx, token)
|
||||
verifyAllowedServices(integrationServices, extra)
|
||||
const { socialId, kind, workspaceUuid, key } = params
|
||||
|
||||
const existing = await db.integrationSecret.findOne({ socialId, kind, workspaceUuid, key })
|
||||
if (existing == null) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationSecretNotFound, {}))
|
||||
}
|
||||
|
||||
return existing
|
||||
}
|
||||
|
||||
export async function listIntegrationsSecrets (
|
||||
ctx: MeasureContext,
|
||||
db: AccountDB,
|
||||
branding: Branding | null,
|
||||
token: string,
|
||||
params: {
|
||||
socialId?: PersonId
|
||||
kind?: string
|
||||
workspaceUuid?: WorkspaceUuid | null
|
||||
key?: string
|
||||
}
|
||||
): Promise<IntegrationSecret[]> {
|
||||
const { extra } = decodeTokenVerbose(ctx, token)
|
||||
verifyAllowedServices(integrationServices, extra)
|
||||
const { socialId, kind, workspaceUuid, key } = params
|
||||
|
||||
return await db.integrationSecret.find({ socialId, kind, workspaceUuid, key })
|
||||
}
|
||||
|
||||
export type AccountServiceMethods =
|
||||
| 'getPendingWorkspace'
|
||||
| 'updateWorkspaceInfo'
|
||||
| 'workerHandshake'
|
||||
| 'updateBackupInfo'
|
||||
| 'assignWorkspace'
|
||||
| 'listWorkspaces'
|
||||
| 'performWorkspaceOperation'
|
||||
| 'updateWorkspaceRoleBySocialKey'
|
||||
| 'addSocialIdToPerson'
|
||||
| 'createIntegration'
|
||||
| 'updateIntegration'
|
||||
| 'deleteIntegration'
|
||||
| 'listIntegrations'
|
||||
| 'getIntegration'
|
||||
| 'addIntegrationSecret'
|
||||
| 'updateIntegrationSecret'
|
||||
| 'deleteIntegrationSecret'
|
||||
| 'getIntegrationSecret'
|
||||
| 'listIntegrationsSecrets'
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function getServiceMethods (): Partial<Record<AccountServiceMethods, AccountMethodHandler>> {
|
||||
return {
|
||||
getPendingWorkspace: wrap(getPendingWorkspace),
|
||||
updateWorkspaceInfo: wrap(updateWorkspaceInfo),
|
||||
workerHandshake: wrap(workerHandshake),
|
||||
updateBackupInfo: wrap(updateBackupInfo),
|
||||
assignWorkspace: wrap(assignWorkspace),
|
||||
listWorkspaces: wrap(listWorkspaces),
|
||||
performWorkspaceOperation: wrap(performWorkspaceOperation),
|
||||
updateWorkspaceRoleBySocialKey: wrap(updateWorkspaceRoleBySocialKey),
|
||||
addSocialIdToPerson: wrap(addSocialIdToPerson),
|
||||
createIntegration: wrap(createIntegration),
|
||||
updateIntegration: wrap(updateIntegration),
|
||||
deleteIntegration: wrap(deleteIntegration),
|
||||
listIntegrations: wrap(listIntegrations),
|
||||
getIntegration: wrap(getIntegration),
|
||||
addIntegrationSecret: wrap(addIntegrationSecret),
|
||||
updateIntegrationSecret: wrap(updateIntegrationSecret),
|
||||
deleteIntegrationSecret: wrap(deleteIntegrationSecret),
|
||||
getIntegrationSecret: wrap(getIntegrationSecret),
|
||||
listIntegrationsSecrets: wrap(listIntegrationsSecrets)
|
||||
}
|
||||
}
|
@ -139,6 +139,25 @@ export interface MailboxInfo {
|
||||
mailbox: string
|
||||
}
|
||||
|
||||
export interface Integration {
|
||||
socialId: PersonId
|
||||
kind: string // Integration kind. E.g. 'github', 'mail', 'telegram-bot', 'telegram' etc.
|
||||
workspaceUuid?: WorkspaceUuid
|
||||
data?: Record<string, any>
|
||||
}
|
||||
|
||||
export type IntegrationKey = Omit<Integration, 'data'>
|
||||
|
||||
export interface IntegrationSecret {
|
||||
socialId: PersonId
|
||||
kind: string // Integration kind. E.g. 'github', 'mail', 'telegram-bot', 'telegram' etc.
|
||||
workspaceUuid?: WorkspaceUuid
|
||||
key: string // Key for the secret in the integration. Different secrets for the same integration must have different keys. Can be any string. E.g. '', 'user_app_1' etc.
|
||||
secret: string
|
||||
}
|
||||
|
||||
export type IntegrationSecretKey = Omit<IntegrationSecret, 'secret'>
|
||||
|
||||
/* ========= S U P P L E M E N T A R Y ========= */
|
||||
|
||||
export interface WorkspaceInfoWithStatus extends Workspace {
|
||||
@ -166,6 +185,8 @@ export interface AccountDB {
|
||||
invite: DbCollection<WorkspaceInvite>
|
||||
mailbox: DbCollection<Mailbox>
|
||||
mailboxSecret: DbCollection<MailboxSecret>
|
||||
integration: DbCollection<Integration>
|
||||
integrationSecret: DbCollection<IntegrationSecret>
|
||||
|
||||
init: () => Promise<void>
|
||||
createWorkspace: (data: WorkspaceData, status: WorkspaceStatusData) => Promise<WorkspaceUuid>
|
||||
|
@ -610,6 +610,53 @@ export async function selectWorkspace (
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateWorkspaceRole (
|
||||
ctx: MeasureContext,
|
||||
db: AccountDB,
|
||||
branding: Branding | null,
|
||||
token: string,
|
||||
params: {
|
||||
targetAccount: PersonUuid
|
||||
targetRole: AccountRole
|
||||
}
|
||||
): Promise<void> {
|
||||
const { targetAccount, targetRole } = params
|
||||
|
||||
const { account, workspace } = decodeTokenVerbose(ctx, token)
|
||||
|
||||
if (workspace === null) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid: workspace }))
|
||||
}
|
||||
|
||||
const accRole = account === systemAccountUuid ? AccountRole.Owner : await db.getWorkspaceRole(account, workspace)
|
||||
|
||||
if (
|
||||
accRole == null ||
|
||||
getRolePower(accRole) < getRolePower(AccountRole.Maintainer) ||
|
||||
getRolePower(accRole) < getRolePower(targetRole)
|
||||
) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||
}
|
||||
|
||||
const currentRole = await db.getWorkspaceRole(targetAccount, workspace)
|
||||
|
||||
if (currentRole == null || getRolePower(accRole) < getRolePower(currentRole)) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||
}
|
||||
|
||||
if (currentRole === targetRole) return
|
||||
|
||||
if (currentRole === AccountRole.Owner) {
|
||||
// Check if there are other owners
|
||||
const owners = (await db.getWorkspaceMembers(workspace)).filter((m) => m.role === AccountRole.Owner)
|
||||
if (owners.length === 1) {
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||
}
|
||||
}
|
||||
|
||||
await db.updateWorkspaceRole(targetAccount, workspace, targetRole)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert workspace name to a URL-friendly string following these rules:
|
||||
*
|
||||
@ -832,7 +879,7 @@ export async function confirmEmail (
|
||||
ctx.error('Email social id not found', { account, normalizedEmail })
|
||||
throw new PlatformError(
|
||||
new Status(Severity.ERROR, platform.status.SocialIdNotFound, {
|
||||
socialId: normalizedEmail,
|
||||
value: normalizedEmail,
|
||||
type: SocialIdType.EMAIL
|
||||
})
|
||||
)
|
||||
|
Loading…
Reference in New Issue
Block a user