UNPKG

@etsoo/appscript

Version:

Applications shared TypeScript framework

2,068 lines (1,807 loc) 61.3 kB
import { INotification, INotifier, NotificationAlign, NotificationCallProps, NotificationContent, NotificationMessageType, NotificationReturn } from "@etsoo/notificationbase"; import { ApiDataError, createClient, IApi, IPData } from "@etsoo/restclient"; import { DataTypes, DateUtils, DomUtils, ErrorData, ErrorType, ExtendUtils, IActionResult, IStorage, ListType, ListType1, NumberUtils, Utils } from "@etsoo/shared"; import { AddressRegion } from "../address/AddressRegion"; import { BridgeUtils } from "../bridges/BridgeUtils"; import { DataPrivacy } from "../business/DataPrivacy"; import { EntityStatus } from "../business/EntityStatus"; import { InitCallDto } from "../api/dto/InitCallDto"; import { ActionResultError } from "../result/ActionResultError"; import { InitCallResult, InitCallResultData } from "../result/InitCallResult"; import { IUser } from "../state/User"; import { IAppSettings } from "./AppSettings"; import { appFields, AppLoginParams, AppTryLoginParams, FormatResultCustomCallback, IApp, IAppFields, IDetectIPCallback, NavigateOptions, RefreshTokenProps } from "./IApp"; import { UserRole } from "./UserRole"; import type CryptoJS from "crypto-js"; import { Currency } from "../business/Currency"; import { ExternalEndpoint, ExternalSettings } from "./ExternalSettings"; import { ApiRefreshTokenDto } from "../api/dto/ApiRefreshTokenDto"; import { ApiRefreshTokenRQ } from "../api/rq/ApiRefreshTokenRQ"; import { AuthApi } from "../api/AuthApi"; type CJType = typeof CryptoJS; let CJ: CJType; const loadCrypto = () => import("crypto-js"); // API refresh token function interface type ApiRefreshTokenFunction = ( api: IApi, rq: ApiRefreshTokenRQ ) => Promise<[string, number] | undefined>; // API task data, [api, token expires in seconds, token expires countdown seconds, app id, refresh token function, token] type ApiTaskData = [IApi, number, number, ApiRefreshTokenFunction, string?]; // System API name const systemApi = "system"; /** * Core application interface */ export interface ICoreApp< U extends IUser, S extends IAppSettings, N, C extends NotificationCallProps > extends IApp { /** * Settings */ readonly settings: S; /** * Notifier */ readonly notifier: INotifier<N, C>; /** * User data */ userData?: U; } /** * Core application */ export abstract class CoreApp< U extends IUser, S extends IAppSettings, N, C extends NotificationCallProps > implements ICoreApp<U, S, N, C> { /** * Settings */ readonly settings: S; /** * Default region */ readonly defaultRegion: AddressRegion; /** * Fields */ readonly fields: IAppFields; /** * API, not recommend to use it directly in code, wrap to separate methods */ readonly api: IApi; /** * Application name */ readonly name: string; /** * Notifier */ readonly notifier: INotifier<N, C>; /** * Storage */ readonly storage: IStorage; /** * Pending actions */ readonly pendings: (() => any)[] = []; /** * Debug mode */ readonly debug: boolean; private _culture!: string; /** * Culture, like zh-CN */ get culture() { return this._culture; } private _currency!: Currency; /** * Currency, like USD for US dollar */ get currency() { return this._currency; } private _region!: string; /** * Country or region, like CN */ get region() { return this._region; } private _deviceId: string; /** * Device id, randome string from ServiceBase.InitCallAsync */ get deviceId() { return this._deviceId; } protected set deviceId(value: string) { this._deviceId = value; this.storage.setData(this.fields.deviceId, this._deviceId); } /** * Label delegate */ get labelDelegate() { return this.get.bind(this); } private _ipData?: IPData; /** * IP data */ get ipData() { return this._ipData; } protected set ipData(value: IPData | undefined) { this._ipData = value; } private _userData?: U; /** * User data */ get userData() { return this._userData; } protected set userData(value: U | undefined) { this._userData = value; } // IP detect ready callbacks private ipDetectCallbacks?: IDetectIPCallback[]; /** * Search input element */ searchInput?: HTMLInputElement; private _authorized: boolean = false; /** * Is current authorized */ get authorized() { return this._authorized; } private set authorized(value: boolean) { this._authorized = value; } private _isReady: boolean = false; /** * Is the app ready */ get isReady() { return this._isReady; } private set isReady(value: boolean) { this._isReady = value; } /** * Current cached URL */ get cachedUrl() { return this.storage.getData(this.fields.cachedUrl); } set cachedUrl(value: string | undefined | null) { this.storage.setData(this.fields.cachedUrl, value); } /** * Keep login or not */ get keepLogin() { return this.storage.getData<boolean>(this.fields.keepLogin) ?? false; } set keepLogin(value: boolean) { const field = this.fields.headerToken; if (!value) { // Clear the token this.clearCacheToken(); // Remove the token field this.persistedFields.remove(field); } else if (!this.persistedFields.includes(field)) { this.persistedFields.push(field); } this.storage.setData(this.fields.keepLogin, value); } private _embedded: boolean; /** * Is embedded */ get embedded() { return this._embedded; } private _isTryingLogin = false; /** * Is trying login */ get isTryingLogin() { return this._isTryingLogin; } protected set isTryingLogin(value: boolean) { this._isTryingLogin = value; } /** * Get core API name */ protected get coreName() { return "core"; } /** * Last called with token refresh */ protected lastCalled = false; /** * Init call Api URL */ protected initCallApi: string = "Auth/WebInitCall"; /** * Passphrase for encryption */ protected passphrase: string = ""; private apis: Record<string, ApiTaskData> = {}; private tasks: [() => PromiseLike<void | false>, number, number][] = []; private clearInterval?: () => void; /** * Get persisted fields */ protected readonly persistedFields: string[]; /** * Protected constructor * @param settings Settings * @param api API * @param notifier Notifier * @param storage Storage * @param name Application name * @param debug Debug mode */ protected constructor( settings: S, api: IApi | undefined | null, notifier: INotifier<N, C>, storage: IStorage, name: string, debug: boolean = false ) { // Format settings this.settings = this.formatSettings(settings); // Current region const region = AddressRegion.getById(this.settings.regions[0]); if (region == null) { throw new Error("No default region defined"); } this.defaultRegion = region; // Current system refresh token const refresh: ApiRefreshTokenFunction = async (api, rq) => { if (this.lastCalled) { // Call refreshToken to update access token await this.refreshToken( { token: rq.token, showLoading: false }, (result) => { if (result === true) return; console.log(`CoreApp.${this.name}.RefreshToken`, result); } ); } else { // Popup countdown for user action this.freshCountdownUI(); } return undefined; }; // Destruct the settings const { currentCulture, currentRegion, endpoint, webUrl } = this.settings; if (api) { // Base URL of the API api.baseUrl = endpoint; api.name = systemApi; this.setApi(api, refresh); this.api = api; } else { this.api = this.createApi( systemApi, { endpoint, webUrl }, refresh ); } this.notifier = notifier; this.storage = storage; this.name = name; this.debug = debug; // Fields, attach with the name identifier this.fields = appFields.reduce( (a, v) => ({ ...a, [v]: "smarterp-" + v + "-" + name }), {} as any ); // Persisted fields this.persistedFields = [ this.fields.deviceId, this.fields.devicePassphrase, this.fields.serversideDeviceId, this.fields.keepLogin ]; // Device id this._deviceId = storage.getData(this.fields.deviceId, ""); // Embedded this._embedded = this.storage.getData<boolean>(this.fields.embedded) ?? false; // Load resources Promise.all([loadCrypto(), this.changeCulture(currentCulture)]).then( ([cj]) => { CJ = cj.default; // Debug if (this.debug) { console.debug( "CoreApp.constructor.ready", this._deviceId, this.fields, cj, currentCulture, currentRegion ); } this.changeRegion(currentRegion); this.setup(); } ); } /** * Format settings * @param settings Original settings * @returns Result */ protected formatSettings(settings: S): S { const { endpoint, webUrl, endpoints, hostname = globalThis.location.hostname, ...rest } = settings; return { ...rest, hostname, endpoint: ExternalSettings.formatHost(endpoint, hostname), webUrl: ExternalSettings.formatHost(webUrl, hostname), endpoints: endpoints == null ? undefined : ExternalSettings.formatHost(endpoints, hostname) } as S; } private getDeviceId() { return this.deviceId.substring(0, 15); } private resetKeys() { this.storage.clear( [ this.fields.devicePassphrase, this.fields.headerToken, this.fields.serversideDeviceId ], false ); this.passphrase = ""; } /** * Add app name as identifier * @param field Field * @returns Result */ protected addIdentifier(field: string) { return field + "-" + this.name; } /** * Add root (homepage) to the URL * @param url URL to add * @returns Result */ addRootUrl(url: string) { const page = this.settings.homepage; const endSlash = page.endsWith("/"); return ( page + (endSlash ? Utils.trimStart(url, "/") : url.startsWith("/") ? url : "/" + url) ); } /** * Check current app is in same session * @param callback Callback */ async checkSession(callback: (isSame: boolean) => Promise<void | false>) { // Session name const sessionName = this.addIdentifier("same-session"); // Current session const isSame = this.storage.getData<boolean>(sessionName) === true; // Callback const result = await callback(isSame); if (!isSame && result !== false) { // Set the session when the callback does not return false this.storage.setData(sessionName, true); } } /** * Clear user session */ clearSession() { this.storage.setData(this.addIdentifier("same-session"), undefined); } /** * Create Auth API * @param api Specify the API to use * @returns Result */ protected createAuthApi(api?: IApi) { return new AuthApi(this, api); } /** * Restore settings from persisted source */ protected async restore() { // Devices const devices = this.storage.getPersistedData<string[]>( this.fields.devices, [] ); if (this._deviceId === "") { // First vist, restore and keep the source this.storage.copyFrom(this.persistedFields, false); // Reset device id this._deviceId = this.storage.getData(this.fields.deviceId, ""); // Totally new, no data restored if (this._deviceId === "") return false; } // Device exists or not const d = this.getDeviceId(); if (devices.includes(d)) { // Duplicate tab, session data copied // Remove the token, deviceId, and passphrase this.resetKeys(); return false; } const passphraseEncrypted = this.storage.getData<string>( this.fields.devicePassphrase ); if (passphraseEncrypted) { // this.name to identifier different app's secret const passphraseDecrypted = this.decrypt(passphraseEncrypted, this.name); if (passphraseDecrypted != null) { // Add the device to the list devices.push(d); this.storage.setPersistedData(this.fields.devices, devices); this.passphrase = passphraseDecrypted; return true; } // Failed, reset keys this.resetKeys(); } return false; } /** * Dispose the application */ dispose() { // Avoid duplicated call if (!this._isReady) return; // Persist storage defined fields this.persist(); // Clear the interval this.clearInterval?.(); // Reset the status to false this.isReady = false; } /** * Is valid password, override to implement custom check * @param password Input password */ isValidPassword(password: string) { // Length check if (password.length < 6) return false; // One letter and number required if (/\d+/gi.test(password) && /[a-z]+/gi.test(password)) { return true; } return false; } /** * Persist settings to source when application exit */ persist() { // Devices const devices = this.storage.getPersistedData<string[]>( this.fields.devices ); if (devices != null) { if (devices.remove(this.getDeviceId()).length > 0) { this.storage.setPersistedData(this.fields.devices, devices); } } if (!this.authorized) return; this.storage.copyTo(this.persistedFields); } /** * Add scheduled task * @param task Task, return false to stop * @param seconds Interval in seconds */ addTask(task: () => PromiseLike<void | false>, seconds: number) { this.tasks.push([task, seconds, seconds]); } /** * Create API client, override to implement custom client creation by name * @param name Client name * @param item External endpoint item * @returns Result */ createApi( name: string, item: ExternalEndpoint, refresh?: ( api: IApi, rq: ApiRefreshTokenRQ ) => Promise<[string, number] | undefined> ) { if (this.apis[name] != null) { throw new Error(`API ${name} already exists`); } const api = createClient(); api.name = name; api.baseUrl = item.endpoint; this.setApi(api, refresh); return api; } /** * Reset all APIs */ protected resetApis() { for (const name in this.apis) { const data = this.apis[name]; this.updateApi(data, undefined, -1); } } /** * Update API token and expires * @param name Api name * @param token Refresh token * @param seconds Access token expires in seconds */ updateApi(name: string, token: string | undefined, seconds: number): void; updateApi( data: ApiTaskData, token: string | undefined, seconds: number ): void; updateApi( nameOrData: string | ApiTaskData, token: string | undefined, seconds: number ) { const api = typeof nameOrData === "string" ? this.apis[nameOrData] : nameOrData; if (api == null) return; // Consider the API call delay if (seconds > 0) { seconds -= 30; if (seconds < 10) seconds = 10; } api[1] = seconds; api[2] = seconds; api[4] = token; } /** * Setup Api * @param api Api */ protected setApi(api: IApi, refresh?: ApiRefreshTokenFunction) { // onRequest, show loading or not, rewrite the property to override default action this.setApiLoading(api); // Global API error handler this.setApiErrorHandler(api); // Setup API countdown refresh ??= this.apiRefreshToken.bind(this); this.apis[api.name] = [api, -1, -1, refresh]; } /** * Setup Api error handler * @param api Api * @param handlerFor401 Handler for 401 error */ setApiErrorHandler( api: IApi, handlerFor401?: boolean | (() => Promise<void>) ) { api.onError = (error: ApiDataError) => { // Debug if (this.debug) { console.debug( `CoreApp.${this.name}.setApiErrorHandler`, api, error, handlerFor401 ); } // Error code const status = error.response ? api.transformResponse(error.response).status : undefined; if (status === 401) { // Unauthorized if (handlerFor401 === false) return; if (typeof handlerFor401 === "function") { handlerFor401(); } else { this.tryLogin(); } return; } else if ( error.response == null && (error.message === "Network Error" || error.message === "Failed to fetch") ) { // Network error this.notifier.alert(this.get("networkError") + ` [${this.name}]`); return; } else { // Log console.error(`${this.name} API error`, error); } // Report the error this.notifier.alert(this.formatError(error)); }; } /** * Setup Api loading * @param api Api */ setApiLoading(api: IApi) { // onRequest, show loading or not, rewrite the property to override default action api.onRequest = (data) => { // Debug if (this.debug) { console.debug( `CoreApp.${this.name}.setApiLoading.onRequest`, api, data, this.notifier.loadingCount ); } if (data.showLoading == null || data.showLoading) { this.notifier.showLoading(); } }; // onComplete, hide loading, rewrite the property to override default action api.onComplete = (data) => { // Debug if (this.debug) { console.debug( `CoreApp.${this.name}.setApiLoading.onComplete`, api, data, this.notifier.loadingCount, this.lastCalled ); } if (data.showLoading == null || data.showLoading) { this.notifier.hideLoading(); // Debug if (this.debug) { console.debug( `CoreApp.${this.name}.setApiLoading.onComplete.showLoading`, api, this.notifier.loadingCount ); } } this.lastCalled = true; }; } /** * Setup frontend logging * @param action Custom action * @param preventDefault Is prevent default action */ setupLogging( action?: (data: ErrorData) => void | Promise<void>, preventDefault?: ((type: ErrorType) => boolean) | boolean ) { action ??= (data) => { this.api.post("Auth/LogFrontendError", data, { onError: (error) => { // Use 'debug' to avoid infinite loop console.debug("Log front-end error", data, error); // Prevent global error handler return false; } }); }; DomUtils.setupLogging(action, preventDefault); } /** * Api init call * @param data Data * @returns Result */ protected async apiInitCall(data: InitCallDto) { return await this.api.put<InitCallResult>(this.initCallApi, data); } /** * Check the action result is about device invalid * @param result Action result * @returns true means device is invalid */ checkDeviceResult(result: IActionResult): boolean { if ( result.type === "DataProcessingFailed" || (result.type === "NoValidData" && result.field === "Device") ) return true; return false; } /** * Clear device id */ clearDeviceId() { this._deviceId = ""; this.storage.clear([this.fields.deviceId], false); this.storage.clear([this.fields.deviceId], true); } /** * Init call * @param callback Callback * @param resetKeys Reset all keys first * @returns Result */ async initCall(callback?: (result: boolean) => void, resetKeys?: boolean) { // Reset keys if (resetKeys) { this.clearDeviceId(); this.resetKeys(); } // Passphrase exists? if (this.passphrase) { if (callback) callback(true); return; } // Serverside encrypted device id const identifier = this.storage.getData<string>( this.fields.serversideDeviceId ); // Timestamp const timestamp = new Date().getTime(); // Request data const data: InitCallDto = { timestamp, identifier, deviceId: this.deviceId ? this.deviceId : undefined }; const result = await this.apiInitCall(data); if (result == null) { // API error will popup if (callback) callback(false); return; } if (result.data == null) { // Popup no data error this.notifier.alert(this.get<string>("noData")!); if (callback) callback(false); return; } if (!result.ok) { const seconds = result.data.seconds; const validSeconds = result.data.validSeconds; if ( result.title === "timeDifferenceInvalid" && seconds != null && validSeconds != null ) { const title = this.get("timeDifferenceInvalid")?.format( seconds.toString(), validSeconds.toString() ); this.notifier.alert(title!); } else { this.alertResult(result); } if (callback) callback(false); // Clear device id this.clearDeviceId(); return; } const updateResult = await this.initCallUpdate(result.data, data.timestamp); if (!updateResult) { this.notifier.alert(this.get<string>("noData")! + "(Update)"); } if (callback) callback(updateResult); } /** * Update passphrase * @param passphrase Secret passphrase */ protected updatePassphrase(passphrase: string) { // Previous passphrase const prev = this.passphrase; // Update this.passphrase = passphrase; this.storage.setData( this.fields.devicePassphrase, this.encrypt(passphrase, this.name) ); if (prev) { const fields = this.initCallEncryptedUpdateFields(); for (const field of fields) { const currentValue = this.storage.getData<string>(field); if (currentValue == null || currentValue === "") continue; if (prev == null) { // Reset the field this.storage.setData(field, undefined); continue; } const enhanced = currentValue.indexOf("!") >= 8; let newValueSource: string | undefined; if (enhanced) { newValueSource = this.decryptEnhanced(currentValue, prev, 12); } else { newValueSource = this.decrypt(currentValue, prev); } if (newValueSource == null || newValueSource === "") { // Reset the field this.storage.setData(field, undefined); continue; } const newValue = enhanced ? this.encryptEnhanced(newValueSource) : this.encrypt(newValueSource); this.storage.setData(field, newValue); } } } /** * Init call update * @param data Result data * @param timestamp Timestamp */ protected async initCallUpdate( data: InitCallResultData, timestamp: number ): Promise<boolean> { // Data check if (data.deviceId == null || data.passphrase == null) return false; // Decrypt // Should be done within 120 seconds after returning from the backend const passphrase = this.decrypt(data.passphrase, timestamp.toString()); if (passphrase == null) return false; // Update device id and cache it this.deviceId = data.deviceId; // Devices const devices = this.storage.getPersistedData<string[]>( this.fields.devices, [] ); devices.push(this.getDeviceId()); this.storage.setPersistedData(this.fields.devices, devices); // Previous passphrase if (data.previousPassphrase) { const prev = this.decrypt(data.previousPassphrase, timestamp.toString()); this.passphrase = prev ?? ""; } // Update passphrase this.updatePassphrase(passphrase); return true; } /** * Init call encrypted fields update * @returns Fields */ protected initCallEncryptedUpdateFields(): string[] { return [this.fields.headerToken]; } /** * Alert result * @param result Result message * @param callback Callback */ alertResult(result: string, callback?: NotificationReturn<void>): void; /** * Alert action result * @param result Action result * @param callback Callback * @param forceToLocal Force to local labels */ alertResult( result: IActionResult, callback?: NotificationReturn<void>, forceToLocal?: FormatResultCustomCallback ): void; alertResult( result: IActionResult | string, callback?: NotificationReturn<void>, forceToLocal?: FormatResultCustomCallback ) { const message = typeof result === "string" ? result : this.formatResult(result, forceToLocal); this.notifier.alert(message, callback); } /** * Authorize * @param token New access token * @param schema Access token schema * @param refreshToken Refresh token */ authorize(token?: string, schema?: string, refreshToken?: string) { // State, when token is null, means logout const authorized = token != null; // Token schema ??= "Bearer"; this.api.authorize(schema, token); // Overwrite the current value if (refreshToken !== "") { if (refreshToken == null) { this.clearCacheToken(); } else { this.saveCacheToken(refreshToken); } } // Reset tryLogin state this._isTryingLogin = false; // Token countdown if (authorized) { this.lastCalled = false; if (refreshToken) { this.updateApi(this.api.name, refreshToken, this.userData!.seconds); } } else { this.updateApi(this.api.name, undefined, -1); } // Host notice BridgeUtils.host?.userAuthorization(authorized); // Callback this.onAuthorized(authorized); // Everything is ready, update the state this.authorized = authorized; // Persist this.persist(); } /** * On authorized or not callback * @param success Success or not */ protected onAuthorized(success: boolean) {} /** * Change country or region * @param regionId New country or region */ changeRegion(region: string | AddressRegion) { // Get data let regionId: string; let regionItem: AddressRegion | undefined; if (typeof region === "string") { regionId = region; regionItem = AddressRegion.getById(region); } else { regionId = region.id; regionItem = region; } // Same if (regionId === this._region) return; // Not included if (regionItem == null || !this.settings.regions.includes(regionId)) return; // Save the id to local storage this.storage.setPersistedData(DomUtils.CountryField, regionId); // Set the currency and culture this._currency = regionItem.currency; this._region = regionId; // Hold the current country or region this.settings.currentRegion = regionItem; this.updateRegionLabel(); } /** * Change culture * @param culture New culture definition * @param onReady On ready callback */ async changeCulture(culture: DataTypes.CultureDefinition) { // Name const { name, resources } = culture; // Same? if (this._culture === name && typeof resources === "object") return resources; // Save the cultrue to local storage this.storage.setPersistedData(DomUtils.CultureField, name); // Change the API's Content-Language header // .net 5 API, UseRequestLocalization, RequestCultureProviders, ContentLanguageHeaderRequestCultureProvider this.api.setContentLanguage(name); // Set the culture this._culture = name; let loadedResources: DataTypes.StringRecord; if (typeof resources !== "object") { // Load resources loadedResources = await resources(); // Load system custom resources await this.loadCustomResources(loadedResources, name); // Set static resources back culture.resources = loadedResources; } else { loadedResources = resources; // Load system custom resources await this.loadCustomResources(loadedResources, name); } // Hold the current resources this.settings.currentCulture = culture; this.updateRegionLabel(); return loadedResources; } /** * Load custom resources, override to implement custom resources * @param resources Resources * @param culture Culture name */ protected loadCustomResources( resources: DataTypes.StringRecord, culture: string ): Promise<void> | void {} /** * Update current region label */ protected updateRegionLabel() { const region = this.settings.currentRegion; region.label = this.getRegionLabel(region.id); } /** * Check language is supported or not, return a valid language when supported * @param language Language * @returns Result */ checkLanguage(language?: string) { if (language) { const [cultrue, match] = DomUtils.getCulture( this.settings.cultures, language ); if (cultrue != null && match != DomUtils.CultureMatch.Default) return cultrue.name; } // Default language return this.culture; } /** * Clear cache data */ clearCacheData() { this.clearCacheToken(); this.storage.setData(this.fields.devicePassphrase, undefined); } /** * Clear cached token */ clearCacheToken() { this.saveCacheToken(undefined); this.storage.setPersistedData(this.fields.headerToken, undefined); } /** * Decrypt message * @param messageEncrypted Encrypted message * @param passphrase Secret passphrase * @returns Pure text */ decrypt(messageEncrypted: string, passphrase?: string) { // Iterations const iterations = parseInt(messageEncrypted.substring(0, 2), 10); if (isNaN(iterations)) return undefined; const { PBKDF2, algo, enc, AES, pad, mode } = CJ; try { const salt = enc.Hex.parse(messageEncrypted.substring(2, 34)); const iv = enc.Hex.parse(messageEncrypted.substring(34, 66)); const encrypted = messageEncrypted.substring(66); const key = PBKDF2(passphrase ?? this.passphrase, salt, { keySize: 8, // 256 / 32 hasher: algo.SHA256, iterations: 1000 * iterations }); return AES.decrypt(encrypted, key, { iv, padding: pad.Pkcs7, mode: mode.CBC }).toString(enc.Utf8); } catch (e) { console.error(`CoreApp.decrypt ${messageEncrypted} error`, e); return undefined; } } /** * Enhanced decrypt message * @param messageEncrypted Encrypted message * @param passphrase Secret passphrase * @param durationSeconds Duration seconds, <= 12 will be considered as month * @returns Pure text */ decryptEnhanced( messageEncrypted: string, passphrase?: string, durationSeconds?: number ) { // Timestamp splitter const pos = messageEncrypted.indexOf("!"); // Miliseconds chars are longer than 8 if (pos < 8 || messageEncrypted.length <= 66) return undefined; const timestamp = messageEncrypted.substring(0, pos); try { if (durationSeconds != null && durationSeconds > 0) { const milseconds = Utils.charsToNumber(timestamp); if (isNaN(milseconds) || milseconds < 1) return undefined; const timespan = new Date().substract(new Date(milseconds)); if ( (durationSeconds <= 12 && timespan.totalMonths > durationSeconds) || (durationSeconds > 12 && timespan.totalSeconds > durationSeconds) ) return undefined; } const message = messageEncrypted.substring(pos + 1); passphrase = this.encryptionEnhance( passphrase ?? this.passphrase, timestamp ); return this.decrypt(message, passphrase); } catch (e) { console.error(`CoreApp.decryptEnhanced ${messageEncrypted} error`, e); return undefined; } } /** * Detect IP data, call only one time * @param callback Callback will be called when the IP is ready */ detectIP(callback?: IDetectIPCallback) { if (this.ipData != null) { if (callback != null) callback(); return; } // First time if (this.ipDetectCallbacks == null) { // Init this.ipDetectCallbacks = []; // Call the API this.api.detectIP().then( (data) => { if (data != null) { // Hold the data this.ipData = data; } this.detectIPCallbacks(); }, (_reason) => this.detectIPCallbacks() ); } if (callback != null) { // Push the callback to the collection this.ipDetectCallbacks.push(callback); } } // Detect IP callbacks private detectIPCallbacks() { this.ipDetectCallbacks?.forEach((f) => f()); } /** * Download file * @param stream File stream * @param filename File name * @param callback callback */ async download( stream: ReadableStream, filename?: string, callback?: (success: boolean | undefined) => void ) { const downloadFile = async () => { let success = await DomUtils.downloadFile( stream, filename, BridgeUtils.host == null ); if (success == null) { success = await DomUtils.downloadFile(stream, filename, false); } if (callback) { callback(success); } else if (success) this.notifier.message( NotificationMessageType.Success, this.get("fileDownloaded")! ); }; // https://developer.mozilla.org/en-US/docs/Web/API/UserActivation/isActive if ( "userActivation" in navigator && !(navigator.userActivation as any).isActive ) { this.notifier.alert(this.get("reactivateTip")!, async () => { await downloadFile(); }); } else { await downloadFile(); } } /** * Encrypt message * @param message Message * @param passphrase Secret passphrase * @param iterations Iterations, 1000 times, 1 - 99 * @returns Result */ encrypt(message: string, passphrase?: string, iterations?: number) { // Default 1 * 1000 iterations ??= 1; const { lib, PBKDF2, algo, enc, AES, pad, mode } = CJ; const bits = 16; // 128 / 8 const salt = lib.WordArray.random(bits); const key = PBKDF2(passphrase ?? this.passphrase, salt, { keySize: 8, // 256 / 32 hasher: algo.SHA256, iterations: 1000 * iterations }); const iv = lib.WordArray.random(bits); return ( iterations.toString().padStart(2, "0") + salt.toString(enc.Hex) + iv.toString(enc.Hex) + AES.encrypt(message, key, { iv, padding: pad.Pkcs7, mode: mode.CBC }).toString() // enc.Base64 ); } /** * Enhanced encrypt message * @param message Message * @param passphrase Secret passphrase * @param iterations Iterations, 1000 times, 1 - 99 * @returns Result */ encryptEnhanced(message: string, passphrase?: string, iterations?: number) { // Timestamp const timestamp = Utils.numberToChars(new Date().getTime()); passphrase = this.encryptionEnhance( passphrase ?? this.passphrase, timestamp ); const result = this.encrypt(message, passphrase, iterations); return timestamp + "!" + result; } /** * Enchance secret passphrase * @param passphrase Secret passphrase * @param timestamp Timestamp * @returns Enhanced passphrase */ protected encryptionEnhance(passphrase: string, timestamp: string) { passphrase += timestamp; passphrase += passphrase.length.toString(); return passphrase; } /** * Format action * @param action Action * @param target Target name or title * @param items More items * @returns Result */ formatAction(action: string, target: string, ...items: string[]) { let more = items.join(", "); return `[${this.get("appName")}] ${action} - ${target}${ more ? `, ${more}` : more }`; } /** * Format date to string * @param input Input date * @param options Options * @param timeZone Time zone * @returns string */ formatDate( input?: Date | string, options?: DateUtils.FormatOptions, timeZone?: string ) { const { currentCulture } = this.settings; timeZone ??= this.getTimeZone(); return DateUtils.format(input, currentCulture.name, options, timeZone); } /** * Format money number * @param input Input money number * @param isInteger Is integer * @param options Options * @returns Result */ formatMoney( input: number | bigint, isInteger: boolean = false, options?: Intl.NumberFormatOptions ) { return NumberUtils.formatMoney( input, this.currency, this.culture, isInteger, options ); } /** * Format number * @param input Input number * @param options Options * @returns Result */ formatNumber(input: number | bigint, options?: Intl.NumberFormatOptions) { return NumberUtils.format(input, this.culture, options); } /** * Format error * @param error Error * @returns Error message */ formatError(error: ApiDataError) { return `${error.message} (${error.name})`; } /** * Format as full name * @param familyName Family name * @param givenName Given name */ formatFullName( familyName: string | undefined | null, givenName: string | undefined | null ) { if (!familyName) return givenName ?? ""; if (!givenName) return familyName ?? ""; const wf = givenName + " " + familyName; if (wf.containChinese() || wf.containJapanese() || wf.containKorean()) { return familyName + givenName; } return wf; } private getFieldLabel(field: string) { return this.get(field.formatInitial(false)) ?? field; } /** * Format result text * @param result Action result * @param forceToLocal Force to local labels */ formatResult( result: IActionResult, forceToLocal?: FormatResultCustomCallback ) { // Destruct the result const { title, type, field } = result; const data = { title, type, field }; if (type === "ItemExists" && field) { // Special case const fieldLabel = (typeof forceToLocal === "function" ? forceToLocal(data) : undefined) ?? this.getFieldLabel(field); result.title = this.get("itemExists")?.format(fieldLabel); } else if (title?.includes("{0}")) { // When title contains {0}, replace with the field label const fieldLabel = (typeof forceToLocal === "function" ? forceToLocal(data) : undefined) ?? (field ? this.getFieldLabel(field) : ""); result.title = title.format(fieldLabel); } else if (title && /^\w+$/.test(title)) { // When title is a single word // Hold the original title in type when type is null if (type == null) result.type = title; const localTitle = (typeof forceToLocal === "function" ? forceToLocal(data) : undefined) ?? this.getFieldLabel(title); result.title = localTitle; } else if ((title == null || forceToLocal) && type != null) { const localTitle = (typeof forceToLocal === "function" ? forceToLocal(data) : undefined) ?? this.getFieldLabel(type); result.title = localTitle; } return ActionResultError.format(result); } /** * Get culture resource * @param key key * @returns Resource */ get<T = string>(key: string): T | undefined { // Make sure the resource files are loaded first const resources = this.settings.currentCulture.resources; const value = typeof resources === "object" ? resources[key] : undefined; if (value == null) return undefined; // No strict type convertion here // Make sure the type is strictly match // Otherwise even request number, may still return the source string type return value as T; } /** * Get multiple culture labels * @param keys Keys */ getLabels<T extends string>(...keys: T[]): { [K in T]: string } { const init: any = {}; return keys.reduce( (a, v) => ({ ...a, [v]: this.get<string>(v) ?? "" }), init ); } /** * Get bool items * @returns Bool items */ getBools(): ListType1[] { const { no = "No", yes = "Yes" } = this.getLabels("no", "yes"); return [ { id: "false", label: no }, { id: "true", label: yes } ]; } /** * Get cached token * @returns Cached token */ getCacheToken(): string | undefined { return this.storage.getData<string>(this.fields.headerToken); } /** * Get data privacies * @returns Result */ getDataPrivacies() { return this.getEnumList(DataPrivacy, "dataPrivacy"); } /** * Get enum item number id list * @param em Enum * @param prefix Label prefix or callback * @param filter Filter * @returns List */ getEnumList<E extends DataTypes.EnumBase = DataTypes.EnumBase>( em: E, prefix: string | ((key: string) => string), filter?: | ((id: E[keyof E], key: keyof E & string) => E[keyof E] | undefined) | E[keyof E][] ): ListType[] { const list: ListType[] = []; const getKey = typeof prefix === "function" ? prefix : (key: string) => prefix + key; if (Array.isArray(filter)) { filter.forEach((id) => { if (typeof id !== "number") return; const key = DataTypes.getEnumKey(em, id); if (key == null) return; const label = this.get<string>(getKey(key)) ?? key; list.push({ id, label }); }); } else { const keys = DataTypes.getEnumKeys(em); for (const key of keys) { let id = em[key as keyof E]; if (filter) { const fid = filter(id, key); if (fid == null) continue; id = fid; } if (typeof id !== "number") continue; const label = this.get<string>(getKey(key)) ?? key; list.push({ id, label }); } } return list; } /** * Get enum item string id list * @param em Enum * @param prefix Label prefix or callback * @param filter Filter * @returns List */ getEnumStrList<E extends DataTypes.EnumBase = DataTypes.EnumBase>( em: E, prefix: string | ((key: string) => string), filter?: (id: E[keyof E], key: keyof E & string) => E[keyof E] | undefined ): ListType1[] { const list: ListType1[] = []; const getKey = typeof prefix === "function" ? prefix : (key: string) => prefix + key; const keys = DataTypes.getEnumKeys(em); for (const key of keys) { let id = em[key as keyof E]; if (filter) { const fid = filter(id, key); if (fid == null) continue; id = fid; } var label = this.get<string>(getKey(key)) ?? key; list.push({ id: id.toString(), label }); } return list; } /** * Get region label * @param id Region id * @returns Label */ getRegionLabel(id: string) { return this.get("region" + id) ?? id; } /** * Get all regions * @returns Regions */ getRegions() { return this.settings.regions.map((id) => { return AddressRegion.getById(id)!; }); } /** * Get role label * @param role Role value * @param joinChar Join char * @returns Label(s) */ getRoleLabel(role: number | null | undefined, joinChar?: string) { if (role == null) return ""; joinChar ??= ", "; const roles = this.getRoles(role); return roles.map((r) => r.label).join(joinChar); } /** * Get roles * @param role Combination role value, null for all roles */ getRoles(role?: number) { if (role == null) return this.getEnumList(UserRole, "role"); return this.getEnumList(UserRole, "role", (id, _key) => { if ((id & role) > 0) return id; }); } /** * Get status list * @param ids Limited ids * @returns list */ getStatusList(ids?: EntityStatus[]) { return this.getEnumList(EntityStatus, "status", ids); } /** * Get status label * @param status Status value */ getStatusLabel(status: number | null | undefined) { if (status == null) return ""; const key = EntityStatus[status]; return this.get<string>("status" + key) ?? key; } /** * Get refresh token from response headers * @param rawResponse Raw response from API call * @param tokenKey Refresh token key * @returns response refresh token */ getResponseToken(rawResponse: any, tokenKey: string): string | null { const response = this.api.transformResponse(rawResponse); if (!response.ok) return null; return this.api.getHeaderValue(response.headers, tokenKey); } /** * Get time zone * @returns Time zone */ getTimeZone() { return this.settings.timeZone ?? Utils.getTimeZone(this.ipData?.timezone); } /** * Hash message, SHA3 or HmacSHA512, 512 as Base64 * https://cryptojs.gitbook.io/docs/ * @param message Message * @param passphrase Secret passphrase */ hash(message: string, passphrase?: string) { const { SHA3, enc, HmacSHA512 } = CJ; if (passphrase == null) return SHA3(message, { outputLength: 512 }).toString(enc.Base64); else return HmacSHA512(message, passphrase).toString(enc.Base64); } /** * Hash message Hex, SHA3 or HmacSHA512, 512 as Base64 * https://cryptojs.gitbook.io/docs/ * @param message Message * @param passphrase Secret passphrase */ hashHex(message: string, passphrase?: string) { const { SHA3, enc, HmacSHA512 } = CJ; if (passphrase == null) return SHA3(message, { outputLength: 512 }).toString(enc.Hex); else return HmacSHA512(message, passphrase).toString(enc.Hex); } /** * Check user has the minimum role permission or not * @param role Minumum role * @returns Result */ hasMinPermission(role: UserRole) { const userRole = this.userData?.role; if (userRole == null) return false; return userRole >= role; } /** * Check user has the specific role permission or not * @param roles Roles to check * @returns Result */ hasPermission(roles: number | UserRole | number[] | UserRole[]): boolean { const userRole = this.userData?.role; if (userRole == null) return false; if (Array.isArray(roles)) { return roles.some((role) => (userRole & role) === role); } // One role check if ((userRole & roles) === roles) return true; return false; } /** * Is admin roles * @returns Result */ isAdminUser() { return this.hasPermission([ UserRole.Executive, UserRole.Admin, UserRole.Founder ]); } /** * Is Finance roles * @returns Result */ isFinanceUser() { return this.hasPermission(UserRole.Finance) || this.isAdminUser(); } /** * Is HR manager roles * @returns Result */ isHRUser() { return this.hasPermission(UserRole.HRManager) || this.isAdminUser(); } /** * Is Manager roles, exclude API user from frontend * @returns Result */ isManagerUser() { return ( this.hasPermission([ UserRole.Manager, UserRole.HRManager, UserRole.Director ]) || this.isFinanceUser() ); } /** * Is user roles * @returns Result */ isUser() { return ( this.hasPermission([UserRole.User, UserRole.Leader]) || this.isManagerUser() ); } /** * Load URL * @param url URL * @param targetOrigin Target origin */ loadUrl(url: string, targetOrigin?: string) { // Is it embeded? if (this.embedded && targetOrigin) { globalThis.parent.postMessage([this.coreName + "Url", url], targetOrigin); } else { if (BridgeUtils.host == null) { globalThis.location.href = url; } else { BridgeUtils.host.loadApp(this.coreName, url); } } } /** * Navigate to Url or delta * @param url Url or delta * @param options Options */ navigate<T extends number | string | URL>( to: T, options?: T extends number ? never : NavigateOptions ) { if (typeof to === "number") { globalThis.history.go(to); } else { const { state, replace = false } = options ?? {}; if (replace) { if (state) globalThis.history.replaceState(state, "", to); else globalThis.location.replace(to); } else { if (state) globalThis.history.pushState(state, "", to); else globalThis.location.assign(to); } } } /** * Notify user with success message * @param callback Popup close callback * @param message Success message */ ok(callback?: NotificationReturn<void>, message?: NotificationContent<N>) { message ??= this.get("operationSucceeded")!; this.notifier.succeed(message, undefined, callback); } /** * Callback where exit a page */ pageExit() { this.lastWarning?.dismiss(); this.notifier.hideLoading(true); } /** * Fresh countdown UI * @param callback Callback */ abstract freshCountdownUI(callback?: () => PromiseLike<unknown>): void; /** * Refresh token * @param props Props * @param callback Callback */ async refres