@etsoo/appscript
Version:
Applications shared TypeScript framework
1,558 lines (1,557 loc) • 61.5 kB
JavaScript
"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);