@etsoo/appscript
Version:
Applications shared TypeScript framework
2,068 lines (1,807 loc) • 61.3 kB
text/typescript
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