// // 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 { type AccountRole, type AccountInfo, BackupStatus, Data, type Person, type PersonUuid, type PersonInfo, Version, type WorkspaceInfoWithStatus, type WorkspaceMemberInfo, WorkspaceMode, concatLink, type WorkspaceUserOperation, type WorkspaceUuid, type PersonId, type SocialIdType, type AccountUuid } from '@hcengineering/core' import platform, { PlatformError, Severity, Status } from '@hcengineering/platform' import type { LoginInfo, MailboxOptions, OtpInfo, WorkspaceLoginInfo, RegionInfo, WorkspaceOperation, MailboxInfo, Integration, IntegrationKey, IntegrationSecret, IntegrationSecretKey, SocialId } from './types' import { getClientTimezone } from './utils' /** @public */ export interface AccountClient { // Static methods getProviders: () => Promise // RPC getUserWorkspaces: () => Promise selectWorkspace: ( workspaceUrl: string, kind?: 'external' | 'internal' | 'byregion', externalRegions?: string[] ) => Promise validateOtp: (email: string, code: string) => Promise loginOtp: (email: string) => Promise getLoginInfoByToken: () => Promise restorePassword: (password: string) => Promise confirm: () => Promise requestPasswordReset: (email: string) => Promise sendInvite: (email: string, role: AccountRole) => Promise resendInvite: (email: string, role: AccountRole) => Promise createInviteLink: ( email: string, role: AccountRole, autoJoin: boolean, firstName: string, lastName: string, navigateUrl?: string, expHours?: number ) => Promise leaveWorkspace: (account: AccountUuid) => Promise changeUsername: (first: string, last: string) => Promise changePassword: (oldPassword: string, newPassword: string) => Promise signUpJoin: ( email: string, password: string, first: string, last: string, inviteId: string ) => Promise join: (email: string, password: string, inviteId: string) => Promise createInvite: (exp: number, emailMask: string, limit: number, role: AccountRole) => Promise checkJoin: (inviteId: string) => Promise checkAutoJoin: (inviteId: string, firstName?: string, lastName?: string) => Promise getWorkspaceInfo: (updateLastVisit?: boolean) => Promise getWorkspacesInfo: (workspaces: WorkspaceUuid[]) => Promise getRegionInfo: () => Promise createWorkspace: (name: string, region?: string) => Promise signUpOtp: (email: string, first: string, last: string) => Promise signUp: (email: string, password: string, first: string, last: string) => Promise login: (email: string, password: string) => Promise getPerson: () => Promise getPersonInfo: (account: PersonUuid) => Promise getSocialIds: () => Promise getWorkspaceMembers: () => Promise updateWorkspaceRole: (account: string, role: AccountRole) => Promise updateWorkspaceName: (name: string) => Promise deleteWorkspace: () => Promise findPersonBySocialKey: (socialKey: string, requireAccount?: boolean) => Promise findPersonBySocialId: (socialId: PersonId, requireAccount?: boolean) => Promise findSocialIdBySocialKey: (socialKey: string) => Promise findFullSocialIdBySocialKey: (socialKey: string) => Promise getMailboxOptions: () => Promise createMailbox: (name: string, domain: string) => Promise<{ mailbox: string, socialId: PersonId }> getMailboxes: () => Promise deleteMailbox: (mailbox: string) => Promise // Service methods workerHandshake: (region: string, version: Data, operation: WorkspaceOperation) => Promise getPendingWorkspace: ( region: string, version: Data, operation: WorkspaceOperation ) => Promise updateWorkspaceInfo: ( wsUuid: string, event: string, version: Data, progress: number, message?: string ) => Promise listWorkspaces: (region?: string | null, mode?: WorkspaceMode | null) => Promise performWorkspaceOperation: ( workspaceId: string | string[], event: WorkspaceUserOperation, ...params: any ) => Promise assignWorkspace: (email: string, workspaceUuid: string, role: AccountRole) => Promise updateBackupInfo: (info: BackupStatus) => Promise updateWorkspaceRoleBySocialKey: (socialKey: string, targetRole: AccountRole) => Promise ensurePerson: ( socialType: SocialIdType, socialValue: string, firstName: string, lastName: string ) => Promise<{ uuid: PersonUuid, socialId: PersonId }> addSocialIdToPerson: ( person: PersonUuid, type: SocialIdType, value: string, confirmed: boolean, displayValue?: string ) => Promise updateSocialId: (personId: PersonId, displayValue: string) => Promise exchangeGuestToken: (token: string) => Promise releaseSocialId: (personUuid: PersonUuid, type: SocialIdType, value: string) => Promise createIntegration: (integration: Integration) => Promise updateIntegration: (integration: Integration) => Promise deleteIntegration: (integrationKey: IntegrationKey) => Promise getIntegration: (integrationKey: IntegrationKey) => Promise listIntegrations: (filter: Partial) => Promise addIntegrationSecret: (integrationSecret: IntegrationSecret) => Promise updateIntegrationSecret: (integrationSecret: IntegrationSecret) => Promise deleteIntegrationSecret: (integrationSecretKey: IntegrationSecretKey) => Promise getIntegrationSecret: (integrationSecretKey: IntegrationSecretKey) => Promise listIntegrationsSecrets: (filter: Partial) => Promise getAccountInfo: (uuid: AccountUuid) => Promise setCookie: () => Promise deleteCookie: () => Promise } /** @public */ export function getClient (accountsUrl?: string, token?: string, retryTimeoutMs?: number): AccountClient { if (accountsUrl === undefined) { throw new Error('Accounts url not specified') } return new AccountClientImpl(accountsUrl, token, retryTimeoutMs) } interface Request { method: string params: Record } class AccountClientImpl implements AccountClient { private readonly request: RequestInit private readonly rpc: typeof this._rpc constructor ( private readonly url: string, private readonly token?: string, retryTimeoutMs?: number ) { if (url === '') { throw new Error('Accounts url not specified') } const isBrowser = typeof window !== 'undefined' this.request = { keepalive: true, headers: { ...(this.token === undefined ? {} : { Authorization: 'Bearer ' + this.token }) }, ...(isBrowser ? { credentials: 'include' } : {}) } this.rpc = withRetryUntilTimeout(this._rpc.bind(this), retryTimeoutMs ?? 5000) } async getProviders (): Promise { return await withRetryUntilMaxAttempts(async () => { const response = await fetch(concatLink(this.url, '/providers')) return await response.json() })() } private async _rpc(request: Request): Promise { const timezone = getClientTimezone() const meta: Record = timezone !== undefined ? { 'x-timezone': timezone } : {} const response = await fetch(this.url, { ...this.request, headers: { ...this.request.headers, 'Content-Type': 'application/json', Connection: 'keep-alive', ...meta }, method: 'POST', body: JSON.stringify(request) }) const result = await response.json() if (result.error != null) { throw new PlatformError(result.error) } return result.result } private flattenStatus (ws: any): WorkspaceInfoWithStatus { if (ws === undefined) { throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, {})) } const status = ws.status if (status === undefined) { return ws } const result = { ...ws, ...status } delete result.status return result } async getUserWorkspaces (): Promise { const request = { method: 'getUserWorkspaces' as const, params: {} } return (await this.rpc(request)).map((ws) => this.flattenStatus(ws)) } async selectWorkspace ( workspaceUrl: string, kind: 'external' | 'internal' | 'byregion' = 'external', externalRegions: string[] = [] ): Promise { const request = { method: 'selectWorkspace' as const, params: { workspaceUrl, kind, externalRegions } } return await this.rpc(request) } async validateOtp (email: string, code: string): Promise { const request = { method: 'validateOtp' as const, params: { email, code } } return await this.rpc(request) } async loginOtp (email: string): Promise { const request = { method: 'loginOtp' as const, params: { email } } return await this.rpc(request) } async getLoginInfoByToken (): Promise { const request = { method: 'getLoginInfoByToken' as const, params: {} } return await this.rpc(request) } async restorePassword (password: string): Promise { const request = { method: 'restorePassword' as const, params: { password } } return await this.rpc(request) } async confirm (): Promise { const request = { method: 'confirm' as const, params: {} } return await this.rpc(request) } async requestPasswordReset (email: string): Promise { const request = { method: 'requestPasswordReset' as const, params: { email } } await this.rpc(request) } async sendInvite (email: string, role: AccountRole): Promise { const request = { method: 'sendInvite' as const, params: { email, role } } await this.rpc(request) } async resendInvite (email: string, role: AccountRole): Promise { const request = { method: 'resendInvite' as const, params: [email, role] } await this.rpc(request) } async createInviteLink ( email: string, role: AccountRole, autoJoin: boolean, firstName: string, lastName: string, navigateUrl?: string, expHours?: number ): Promise { const request = { method: 'createInviteLink' as const, params: { email, role, autoJoin, firstName, lastName, navigateUrl, expHours } } return await this.rpc(request) } async leaveWorkspace (account: AccountUuid): Promise { const request = { method: 'leaveWorkspace' as const, params: { account } } return await this.rpc(request) } async changeUsername (first: string, last: string): Promise { const request = { method: 'changeUsername' as const, params: { first, last } } await this.rpc(request) } async changePassword (oldPassword: string, newPassword: string): Promise { const request = { method: 'changePassword' as const, params: { oldPassword, newPassword } } await this.rpc(request) } async signUpJoin ( email: string, password: string, first: string, last: string, inviteId: string ): Promise { const request = { method: 'signUpJoin' as const, params: { email, password, first, last, inviteId } } return await this.rpc(request) } async join (email: string, password: string, inviteId: string): Promise { const request = { method: 'join' as const, params: { email, password, inviteId } } return await this.rpc(request) } async createInvite (exp: number, emailMask: string, limit: number, role: AccountRole): Promise { const request = { method: 'createInvite' as const, params: { exp, emailMask, limit, role } } return await this.rpc(request) } async checkJoin (inviteId: string): Promise { const request = { method: 'checkJoin' as const, params: { inviteId } } return await this.rpc(request) } async checkAutoJoin (inviteId: string, firstName?: string, lastName?: string): Promise { const request = { method: 'checkAutoJoin' as const, params: { inviteId, firstName, lastName } } return await this.rpc(request) } async getWorkspacesInfo (ids: WorkspaceUuid[]): Promise { const request = { method: 'getWorkspacesInfo' as const, params: { ids } } return await this.rpc(request) } async getWorkspaceInfo (updateLastVisit: boolean = false): Promise { const request = { method: 'getWorkspaceInfo' as const, params: updateLastVisit ? { updateLastVisit: true } : {} } return this.flattenStatus(await this.rpc(request)) } async getRegionInfo (): Promise { const request = { method: 'getRegionInfo' as const, params: {} } return await this.rpc(request) } async createWorkspace (workspaceName: string, region?: string): Promise { const request = { method: 'createWorkspace' as const, params: { workspaceName, region } } return await this.rpc(request) } async signUpOtp (email: string, firstName: string, lastName: string): Promise { const request = { method: 'signUpOtp' as const, params: { email, firstName, lastName } } return await this.rpc(request) } async signUp (email: string, password: string, firstName: string, lastName: string): Promise { const request = { method: 'signUp' as const, params: { email, password, firstName, lastName } } return await this.rpc(request) } async login (email: string, password: string): Promise { const request = { method: 'login' as const, params: { email, password } } return await this.rpc(request) } async getPerson (): Promise { const request = { method: 'getPerson' as const, params: {} } return await this.rpc(request) } async getPersonInfo (account: PersonUuid): Promise { const request = { method: 'getPersonInfo' as const, params: { account } } return await this.rpc(request) } async getSocialIds (): Promise { const request = { method: 'getSocialIds' as const, params: {} } return await this.rpc(request) } async workerHandshake (region: string, version: Data, operation: WorkspaceOperation): Promise { const request = { method: 'workerHandshake' as const, params: { region, version, operation } } await this.rpc(request) } async getPendingWorkspace ( region: string, version: Data, operation: WorkspaceOperation ): Promise { const request = { method: 'getPendingWorkspace' as const, params: { region, version, operation } } const result = await this.rpc(request) if (result == null) { return null } return this.flattenStatus(result) } async updateWorkspaceInfo ( workspaceUuid: string, event: string, version: Data, progress: number, message?: string ): Promise { const request = { method: 'updateWorkspaceInfo' as const, params: { workspaceUuid, event, version, progress, message } } await this.rpc(request) } async getWorkspaceMembers (): Promise { const request = { method: 'getWorkspaceMembers' as const, params: {} } return await this.rpc(request) } async updateWorkspaceRole (targetAccount: string, targetRole: AccountRole): Promise { const request = { method: 'updateWorkspaceRole' as const, params: { targetAccount, targetRole } } await this.rpc(request) } async updateWorkspaceName (name: string): Promise { const request = { method: 'updateWorkspaceName' as const, params: { name } } await this.rpc(request) } async deleteWorkspace (): Promise { const request = { method: 'deleteWorkspace' as const, params: {} } await this.rpc(request) } async findPersonBySocialKey (socialString: string, requireAccount?: boolean): Promise { const request = { method: 'findPersonBySocialKey' as const, params: { socialString, requireAccount } } return await this.rpc(request) } async findPersonBySocialId (socialId: PersonId, requireAccount?: boolean): Promise { const request = { method: 'findPersonBySocialId' as const, params: { socialId, requireAccount } } return await this.rpc(request) } async findSocialIdBySocialKey (socialKey: string): Promise { const request = { method: 'findSocialIdBySocialKey' as const, params: { socialKey } } return await this.rpc(request) } async findFullSocialIdBySocialKey (socialKey: string): Promise { const request = { method: 'findFullSocialIdBySocialKey' as const, params: { socialKey } } return await this.rpc(request) } async listWorkspaces (region?: string | null, mode: WorkspaceMode | null = null): Promise { const request = { method: 'listWorkspaces' as const, params: { region, mode } } return ((await this.rpc(request)) ?? []).map((ws) => this.flattenStatus(ws)) } async performWorkspaceOperation ( workspaceId: string | string[], event: WorkspaceUserOperation, ...params: any ): Promise { const request = { method: 'performWorkspaceOperation' as const, params: { workspaceId, event, params } } return await this.rpc(request) } async updateBackupInfo (backupInfo: BackupStatus): Promise { const request = { method: 'updateBackupInfo' as const, params: { backupInfo } } await this.rpc(request) } async assignWorkspace (email: string, workspaceUuid: string, role: AccountRole): Promise { const request = { method: 'assignWorkspace' as const, params: { email, workspaceUuid, role } } await this.rpc(request) } async updateWorkspaceRoleBySocialKey (socialKey: string, targetRole: AccountRole): Promise { const request = { method: 'updateWorkspaceRoleBySocialKey' as const, params: { socialKey, targetRole } } await this.rpc(request) } async ensurePerson ( socialType: SocialIdType, socialValue: string, firstName: string, lastName: string ): Promise<{ uuid: PersonUuid, socialId: PersonId }> { const request = { method: 'ensurePerson' as const, params: { socialType, socialValue, firstName, lastName } } return await this.rpc(request) } async exchangeGuestToken (token: string): Promise { const request = { method: 'exchangeGuestToken' as const, params: { token } } return await this.rpc(request) } async addSocialIdToPerson ( person: PersonUuid, type: SocialIdType, value: string, confirmed: boolean, displayValue?: string ): Promise { const request = { method: 'addSocialIdToPerson' as const, params: { person, type, value, confirmed, displayValue } } return await this.rpc(request) } async updateSocialId (personId: PersonId, displayValue: string): Promise { const request = { method: 'updateSocialId' as const, params: { personId, displayValue } } return await this.rpc(request) } async getMailboxOptions (): Promise { const request = { method: 'getMailboxOptions' as const, params: {} } return await this.rpc(request) } async createMailbox (name: string, domain: string): Promise<{ mailbox: string, socialId: PersonId }> { const request = { method: 'createMailbox' as const, params: { name, domain } } return await this.rpc(request) } async getMailboxes (): Promise { const request = { method: 'getMailboxes' as const, params: {} } return await this.rpc(request) } async deleteMailbox (mailbox: string): Promise { const request = { method: 'deleteMailbox' as const, params: { mailbox } } await this.rpc(request) } async releaseSocialId (personUuid: PersonUuid, type: SocialIdType, value: string): Promise { const request = { method: 'releaseSocialId' as const, params: { personUuid, type, value } } await this.rpc(request) } async createIntegration (integration: Integration): Promise { const request = { method: 'createIntegration' as const, params: integration } await this.rpc(request) } async updateIntegration (integration: Integration): Promise { const request = { method: 'updateIntegration' as const, params: integration } await this.rpc(request) } async deleteIntegration (integrationKey: IntegrationKey): Promise { const request = { method: 'deleteIntegration' as const, params: integrationKey } await this.rpc(request) } async getIntegration (integrationKey: IntegrationKey): Promise { const request = { method: 'getIntegration' as const, params: integrationKey } return await this.rpc(request) } async listIntegrations (filter: Partial): Promise { const request = { method: 'listIntegrations' as const, params: filter } return await this.rpc(request) } async addIntegrationSecret (integrationSecret: IntegrationSecret): Promise { const request = { method: 'addIntegrationSecret' as const, params: integrationSecret } await this.rpc(request) } async updateIntegrationSecret (integrationSecret: IntegrationSecret): Promise { const request = { method: 'updateIntegrationSecret' as const, params: integrationSecret } await this.rpc(request) } async deleteIntegrationSecret (integrationSecretKey: IntegrationSecretKey): Promise { const request = { method: 'deleteIntegrationSecret' as const, params: integrationSecretKey } await this.rpc(request) } async getIntegrationSecret (integrationSecretKey: IntegrationSecretKey): Promise { const request = { method: 'getIntegrationSecret' as const, params: integrationSecretKey } return await this.rpc(request) } async listIntegrationsSecrets (filter: Partial): Promise { const request = { method: 'listIntegrationsSecrets' as const, params: filter } return await this.rpc(request) } async getAccountInfo (uuid: AccountUuid): Promise { const request = { method: 'getAccountInfo' as const, params: { accountId: uuid } } return await this.rpc(request) } async setCookie (): Promise { const url = concatLink(this.url, '/cookie') const response = await fetch(url, { ...this.request, method: 'PUT' }) if (!response.ok) { const result = await response.json() if (result.error != null) { throw new PlatformError(result.error) } } } async deleteCookie (): Promise { const url = concatLink(this.url, '/cookie') const response = await fetch(url, { ...this.request, method: 'DELETE' }) if (!response.ok) { const result = await response.json() if (result.error != null) { throw new PlatformError(result.error) } } } } function withRetry Promise> ( f: F, shouldFail: (err: any, attempt: number) => boolean, intervalMs: number = 25 ): F { return async function (...params: any[]): Promise { let attempt = 0 while (true) { try { return await f(...params) } catch (err: any) { if (shouldFail(err, attempt)) { throw err } attempt++ await new Promise((resolve) => setTimeout(resolve, intervalMs)) if (intervalMs < 1000) { intervalMs += 100 } } } } as F } const connectionErrorCodes = ['ECONNRESET', 'ECONNREFUSED', 'ENOTFOUND'] function withRetryUntilTimeout Promise> (f: F, timeoutMs: number = 5000): F { const timeout = Date.now() + timeoutMs const shouldFail = (err: any): boolean => !connectionErrorCodes.includes(err?.cause?.code) || timeout < Date.now() return withRetry(f, shouldFail) } function withRetryUntilMaxAttempts Promise> (f: F, maxAttempts: number = 5): F { const shouldFail = (err: any, attempt: number): boolean => !connectionErrorCodes.includes(err?.cause?.code) || attempt === maxAttempts return withRetry(f, shouldFail) }