UNPKG

@etsoo/appscript

Version:

Applications shared TypeScript framework

1,558 lines (1,557 loc) 61.5 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.CoreApp = void 0; const notificationbase_1 = require("@etsoo/notificationbase"); const restclient_1 = require("@etsoo/restclient"); const shared_1 = require("@etsoo/shared"); const AddressRegion_1 = require("../address/AddressRegion"); const BridgeUtils_1 = require("../bridges/BridgeUtils"); const DataPrivacy_1 = require("../business/DataPrivacy"); const EntityStatus_1 = require("../business/EntityStatus"); const ActionResultError_1 = require("../result/ActionResultError"); const IApp_1 = require("./IApp"); const UserRole_1 = require("./UserRole"); const ExternalSettings_1 = require("./ExternalSettings"); const AuthApi_1 = require("../api/AuthApi"); let CJ; const loadCrypto = () => import("crypto-js"); // System API name const systemApi = "system"; /** * Core application */ class CoreApp { /** * Culture, like zh-CN */ get culture() { return this._culture; } /** * Currency, like USD for US dollar */ get currency() { return this._currency; } /** * Country or region, like CN */ get region() { return this._region; } /** * Device id, randome string from ServiceBase.InitCallAsync */ get deviceId() { return this._deviceId; } set deviceId(value) { this._deviceId = value; this.storage.setData(this.fields.deviceId, this._deviceId); } /** * Label delegate */ get labelDelegate() { return this.get.bind(this); } /** * IP data */ get ipData() { return this._ipData; } set ipData(value) { this._ipData = value; } /** * User data */ get userData() { return this._userData; } set userData(value) { this._userData = value; } /** * Is current authorized */ get authorized() { return this._authorized; } set authorized(value) { this._authorized = value; } /** * Is the app ready */ get isReady() { return this._isReady; } set isReady(value) { this._isReady = value; } /** * Current cached URL */ get cachedUrl() { return this.storage.getData(this.fields.cachedUrl); } set cachedUrl(value) { this.storage.setData(this.fields.cachedUrl, value); } /** * Keep login or not */ get keepLogin() { return this.storage.getData(this.fields.keepLogin) ?? false; } set keepLogin(value) { 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); } /** * Is embedded */ get embedded() { return this._embedded; } /** * Is trying login */ get isTryingLogin() { return this._isTryingLogin; } set isTryingLogin(value) { this._isTryingLogin = value; } /** * Get core API name */ get coreName() { return "core"; } /** * Protected constructor * @param settings Settings * @param api API * @param notifier Notifier * @param storage Storage * @param name Application name * @param debug Debug mode */ constructor(settings, api, notifier, storage, name, debug = false) { /** * Pending actions */ this.pendings = []; this._authorized = false; this._isReady = false; this._isTryingLogin = false; /** * Last called with token refresh */ this.lastCalled = false; /** * Init call Api URL */ this.initCallApi = "Auth/WebInitCall"; /** * Passphrase for encryption */ this.passphrase = ""; this.apis = {}; this.tasks = []; // Format settings this.settings = this.formatSettings(settings); // Current region const region = AddressRegion_1.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 = 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 = IApp_1.appFields.reduce((a, v) => ({ ...a, [v]: "smarterp-" + v + "-" + name }), {}); // 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(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 */ formatSettings(settings) { const { endpoint, webUrl, endpoints, hostname = globalThis.location.hostname, ...rest } = settings; return { ...rest, hostname, endpoint: ExternalSettings_1.ExternalSettings.formatHost(endpoint, hostname), webUrl: ExternalSettings_1.ExternalSettings.formatHost(webUrl, hostname), endpoints: endpoints == null ? undefined : ExternalSettings_1.ExternalSettings.formatHost(endpoints, hostname) }; } getDeviceId() { return this.deviceId.substring(0, 15); } 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 */ addIdentifier(field) { return field + "-" + this.name; } /** * Add root (homepage) to the URL * @param url URL to add * @returns Result */ addRootUrl(url) { const page = this.settings.homepage; const endSlash = page.endsWith("/"); return (page + (endSlash ? shared_1.Utils.trimStart(url, "/") : url.startsWith("/") ? url : "/" + url)); } /** * Check current app is in same session * @param callback Callback */ async checkSession(callback) { // Session name const sessionName = this.addIdentifier("same-session"); // Current session const isSame = this.storage.getData(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 */ createAuthApi(api) { return new AuthApi_1.AuthApi(this, api); } /** * Restore settings from persisted source */ async restore() { // Devices const devices = this.storage.getPersistedData(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(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) { // 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(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, seconds) { 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, item, refresh) { if (this.apis[name] != null) { throw new Error(`API ${name} already exists`); } const api = (0, restclient_1.createClient)(); api.name = name; api.baseUrl = item.endpoint; this.setApi(api, refresh); return api; } /** * Reset all APIs */ resetApis() { for (const name in this.apis) { const data = this.apis[name]; this.updateApi(data, undefined, -1); } } updateApi(nameOrData, token, seconds) { 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 */ setApi(api, refresh) { // 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 ?? (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, handlerFor401) { api.onError = (error) => { // 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) { // 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, preventDefault) { action ?? (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; } }); }); shared_1.DomUtils.setupLogging(action, preventDefault); } /** * Api init call * @param data Data * @returns Result */ async apiInitCall(data) { return await this.api.put(this.initCallApi, data); } /** * Check the action result is about device invalid * @param result Action result * @returns true means device is invalid */ checkDeviceResult(result) { 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, resetKeys) { // 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(this.fields.serversideDeviceId); // Timestamp const timestamp = new Date().getTime(); // Request data const data = { 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("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("noData") + "(Update)"); } if (callback) callback(updateResult); } /** * Update passphrase * @param passphrase Secret passphrase */ updatePassphrase(passphrase) { // 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(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; 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 */ async initCallUpdate(data, timestamp) { // 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(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 */ initCallEncryptedUpdateFields() { return [this.fields.headerToken]; } alertResult(result, callback, forceToLocal) { 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, schema, refreshToken) { // State, when token is null, means logout const authorized = token != null; // Token schema ?? (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_1.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 */ onAuthorized(success) { } /** * Change country or region * @param regionId New country or region */ changeRegion(region) { // Get data let regionId; let regionItem; if (typeof region === "string") { regionId = region; regionItem = AddressRegion_1.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(shared_1.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) { // Name const { name, resources } = culture; // Same? if (this._culture === name && typeof resources === "object") return resources; // Save the cultrue to local storage this.storage.setPersistedData(shared_1.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; 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 */ loadCustomResources(resources, culture) { } /** * Update current region label */ 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) { if (language) { const [cultrue, match] = shared_1.DomUtils.getCulture(this.settings.cultures, language); if (cultrue != null && match != shared_1.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, passphrase) { // 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, passphrase, durationSeconds) { // 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 = shared_1.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) { 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 detectIPCallbacks() { this.ipDetectCallbacks?.forEach((f) => f()); } /** * Download file * @param stream File stream * @param filename File name * @param callback callback */ async download(stream, filename, callback) { const downloadFile = async () => { let success = await shared_1.DomUtils.downloadFile(stream, filename, BridgeUtils_1.BridgeUtils.host == null); if (success == null) { success = await shared_1.DomUtils.downloadFile(stream, filename, false); } if (callback) { callback(success); } else if (success) this.notifier.message(notificationbase_1.NotificationMessageType.Success, this.get("fileDownloaded")); }; // https://developer.mozilla.org/en-US/docs/Web/API/UserActivation/isActive if ("userActivation" in navigator && !navigator.userActivation.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, passphrase, iterations) { // Default 1 * 1000 iterations ?? (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, passphrase, iterations) { // Timestamp const timestamp = shared_1.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 */ encryptionEnhance(passphrase, timestamp) { 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, target, ...items) { 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, options, timeZone) { const { currentCulture } = this.settings; timeZone ?? (timeZone = this.getTimeZone()); return shared_1.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, isInteger = false, options) { return shared_1.NumberUtils.formatMoney(input, this.currency, this.culture, isInteger, options); } /** * Format number * @param input Input number * @param options Options * @returns Result */ formatNumber(input, options) { return shared_1.NumberUtils.format(input, this.culture, options); } /** * Format error * @param error Error * @returns Error message */ formatError(error) { return `${error.message} (${error.name})`; } /** * Format as full name * @param familyName Family name * @param givenName Given name */ formatFullName(familyName, givenName) { if (!familyName) return givenName ?? ""; if (!givenName) return familyName ?? ""; const wf = givenName + " " + familyName; if (wf.containChinese() || wf.containJapanese() || wf.containKorean()) { return familyName + givenName; } return wf; } getFieldLabel(field) { return this.get(field.formatInitial(false)) ?? field; } /** * Format result text * @param result Action result * @param forceToLocal Force to local labels */ formatResult(result, forceToLocal) { // 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_1.ActionResultError.format(result); } /** * Get culture resource * @param key key * @returns Resource */ get(key) { // 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; } /** * Get multiple culture labels * @param keys Keys */ getLabels(...keys) { const init = {}; return keys.reduce((a, v) => ({ ...a, [v]: this.get(v) ?? "" }), init); } /** * Get bool items * @returns Bool items */ getBools() { 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() { return this.storage.getData(this.fields.headerToken); } /** * Get data privacies * @returns Result */ getDataPrivacies() { return this.getEnumList(DataPrivacy_1.DataPrivacy, "dataPrivacy"); } /** * Get enum item number id list * @param em Enum * @param prefix Label prefix or callback * @param filter Filter * @returns List */ getEnumList(em, prefix, filter) { const list = []; const getKey = typeof prefix === "function" ? prefix : (key) => prefix + key; if (Array.isArray(filter)) { filter.forEach((id) => { if (typeof id !== "number") return; const key = shared_1.DataTypes.getEnumKey(em, id); if (key == null) return; const label = this.get(getKey(key)) ?? key; list.push({ id, label }); }); } else { const keys = shared_1.DataTypes.getEnumKeys(em); for (const key of keys) { let id = em[key]; if (filter) { const fid = filter(id, key); if (fid == null) continue; id = fid; } if (typeof id !== "number") continue; const label = this.get(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(em, prefix, filter) { const list = []; const getKey = typeof prefix === "function" ? prefix : (key) => prefix + key; const keys = shared_1.DataTypes.getEnumKeys(em); for (const key of keys) { let id = em[key]; if (filter) { const fid = filter(id, key); if (fid == null) continue; id = fid; } var label = this.get(getKey(key)) ?? key; list.push({ id: id.toString(), label }); } return list; } /** * Get region label * @param id Region id * @returns Label */ getRegionLabel(id) { return this.get("region" + id) ?? id; } /** * Get all regions * @returns Regions */ getRegions() { return this.settings.regions.map((id) => { return AddressRegion_1.AddressRegion.getById(id); }); } /** * Get role label * @param role Role value * @param joinChar Join char * @returns Label(s) */ getRoleLabel(role, joinChar) { if (role == null) return ""; joinChar ?? (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) { if (role == null) return this.getEnumList(UserRole_1.UserRole, "role"); return this.getEnumList(UserRole_1.UserRole, "role", (id, _key) => { if ((id & role) > 0) return id; }); } /** * Get status list * @param ids Limited ids * @returns list */ getStatusList(ids) { return this.getEnumList(EntityStatus_1.EntityStatus, "status", ids); } /** * Get status label * @param status Status value */ getStatusLabel(status) { if (status == null) return ""; const key = EntityStatus_1.EntityStatus[status]; return this.get("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, tokenKey) { 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 ?? shared_1.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, passphrase) { 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, passphrase) { 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) { 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) { 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_1.UserRole.Executive, UserRole_1.UserRole.Admin, UserRole_1.UserRole.Founder ]); } /** * Is Finance roles * @returns Result */ isFinanceUser() { return this.hasPermission(UserRole_1.UserRole.Finance) || this.isAdminUser(); } /** * Is HR manager roles * @returns Result */ isHRUser() { return this.hasPermission(UserRole_1.UserRole.HRManager) || this.isAdminUser(); } /** * Is Manager roles, exclude API user from frontend * @returns Result */ isManagerUser() { return (this.hasPermission([ UserRole_1.UserRole.Manager, UserRole_1.UserRole.HRManager, UserRole_1.UserRole.Director ]) || this.isFinanceUser()); } /** * Is user roles * @returns Result */ isUser() { return (this.hasPermission([UserRole_1.UserRole.User, UserRole_1.UserRole.Leader]) || this.isManagerUser()); } /** * Load URL * @param url URL * @param targetOrigin Target origin */ loadUrl(url, targetOrigin) { // Is it embeded? if (this.embedded && targetOrigin) { globalThis.parent.postMessage([this.coreName + "Url", url], targetOrigin); } else { if (BridgeUtils_1.BridgeUtils.host == null) { globalThis.location.href = url; } else { BridgeUtils_1.BridgeUtils.host.loadApp(this.coreName, url); } } } /** * Navigate to Url or delta * @param url Url or delta * @param options Options */ navigate(to, options) { 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, message) { message ?? (message = this.get("operationSucceeded")); this.notifier.succeed(message, undefined, callback); } /** * Callback where exit a page */ pageExit() { this.lastWarning?.dismiss(); this.notifier.hideLoading(true); } /** * Refresh token * @param props Props * @param callback Callback */ async refreshToken(props, callback) { // Check props props ?? (props = {}); props.token ?? (props.token = this.getCacheToken()); // Call refresh token API let data = await this.createAuthApi().refreshToken(props); let r; if (Array.isArray(data)) { const [token, result] = data; if (result.ok) { if (!token) { data = { ok: false, type: "noData", field: "token", title: this.get("noData") }; } else if (result.data == null) { data = { ok: false, type: "noData", field: "user", title: this.get("noData") }; } else { // Further processing await this.refreshTokenSucceed(result.data, token, callback);