@etsoo/appscript 1.5.19 → 1.5.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ /**
2
+ * API refresh token data
3
+ */
4
+ export type ApiRefreshTokenDto = {
5
+ /**
6
+ * Refresh token
7
+ */
8
+ readonly refreshToken: string;
9
+ /**
10
+ * Access token
11
+ */
12
+ readonly accessToken: string;
13
+ /**
14
+ * Token type
15
+ */
16
+ readonly tokenType: string;
17
+ /**
18
+ * Expires in
19
+ */
20
+ readonly expiresIn: number;
21
+ };
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@etsoo/appscript",
3
- "version": "1.5.19",
3
+ "version": "1.5.21",
4
4
  "description": "Applications shared TypeScript framework",
5
5
  "main": "lib/cjs/index.js",
6
6
  "module": "lib/mjs/index.js",
@@ -52,17 +52,17 @@
52
52
  },
53
53
  "homepage": "https://github.com/ETSOO/AppScript#readme",
54
54
  "dependencies": {
55
- "@etsoo/notificationbase": "^1.1.47",
56
- "@etsoo/restclient": "^1.1.8",
57
- "@etsoo/shared": "^1.2.44",
55
+ "@etsoo/notificationbase": "^1.1.48",
56
+ "@etsoo/restclient": "^1.1.10",
57
+ "@etsoo/shared": "^1.2.46",
58
58
  "crypto-js": "^4.2.0"
59
59
  },
60
60
  "devDependencies": {
61
- "@babel/cli": "^7.25.6",
62
- "@babel/core": "^7.25.2",
63
- "@babel/plugin-transform-runtime": "^7.25.4",
64
- "@babel/preset-env": "^7.25.4",
65
- "@babel/runtime-corejs3": "^7.25.6",
61
+ "@babel/cli": "^7.25.7",
62
+ "@babel/core": "^7.25.7",
63
+ "@babel/plugin-transform-runtime": "^7.25.7",
64
+ "@babel/preset-env": "^7.25.7",
65
+ "@babel/runtime-corejs3": "^7.25.7",
66
66
  "@types/crypto-js": "^4.2.2",
67
67
  "@types/jest": "^29.5.13",
68
68
  "jest": "^29.7.0",
@@ -7,13 +7,14 @@ import {
7
7
  NotificationMessageType,
8
8
  NotificationReturn
9
9
  } from '@etsoo/notificationbase';
10
- import { ApiDataError, IApi, IPData } from '@etsoo/restclient';
10
+ import { ApiDataError, createClient, IApi, IPData } from '@etsoo/restclient';
11
11
  import {
12
12
  DataTypes,
13
13
  DateUtils,
14
14
  DomUtils,
15
15
  ErrorData,
16
16
  ErrorType,
17
+ ExtendUtils,
17
18
  IActionResult,
18
19
  IStorage,
19
20
  ListType,
@@ -43,12 +44,23 @@ import {
43
44
  import { UserRole } from './UserRole';
44
45
  import type CryptoJS from 'crypto-js';
45
46
  import { Currency } from '../business/Currency';
47
+ import { ExternalEndpoint } from './ExternalSettings';
48
+ import { ApiRefreshTokenDto } from '../erp/dto/ApiRefreshTokenDto';
46
49
 
47
50
  type CJType = typeof CryptoJS;
48
51
  let CJ: CJType;
49
52
 
50
53
  const loadCrypto = () => import('crypto-js');
51
54
 
55
+ // API refresh token function interface
56
+ type ApiRefreshTokenFunction = (
57
+ api: IApi,
58
+ token: string
59
+ ) => Promise<[string, number] | undefined>;
60
+
61
+ // API task data
62
+ type ApiTaskData = [IApi, number, number, ApiRefreshTokenFunction, string?];
63
+
52
64
  /**
53
65
  * Core application interface
54
66
  */
@@ -229,11 +241,6 @@ export abstract class CoreApp<
229
241
  */
230
242
  protected lastCalled = false;
231
243
 
232
- /**
233
- * Token refresh count down seed
234
- */
235
- protected refreshCountdownSeed = 0;
236
-
237
244
  /**
238
245
  * Init call Api URL
239
246
  */
@@ -246,6 +253,10 @@ export abstract class CoreApp<
246
253
 
247
254
  private cachedRefreshToken?: string;
248
255
 
256
+ private apis: Record<string, ApiTaskData> = {};
257
+
258
+ private tasks: [() => PromiseLike<void | false>, number, number][] = [];
259
+
249
260
  /**
250
261
  * Get persisted fields
251
262
  */
@@ -269,7 +280,7 @@ export abstract class CoreApp<
269
280
  */
270
281
  protected constructor(
271
282
  settings: S,
272
- api: IApi,
283
+ api: IApi | undefined | null,
273
284
  notifier: INotifier<N, C>,
274
285
  storage: IStorage,
275
286
  name: string,
@@ -286,7 +297,34 @@ export abstract class CoreApp<
286
297
  }
287
298
  this.defaultRegion = region;
288
299
 
289
- this.api = api;
300
+ const refresh: ApiRefreshTokenFunction = async (api, token) => {
301
+ if (this.lastCalled) {
302
+ // Call refreshToken to update access token
303
+ await this.refreshToken();
304
+ } else {
305
+ // Popup countdown for user action
306
+ this.freshCountdownUI();
307
+ }
308
+ return undefined;
309
+ };
310
+
311
+ if (api) {
312
+ // Base URL of the API
313
+ api.baseUrl = this.settings.endpoint;
314
+ api.name = 'system';
315
+ this.setApi(api, refresh);
316
+ this.api = api;
317
+ } else {
318
+ this.api = this.createApi(
319
+ 'system',
320
+ {
321
+ endpoint: settings.endpoint,
322
+ webUrl: settings.webUrl
323
+ },
324
+ refresh
325
+ );
326
+ }
327
+
290
328
  this.notifier = notifier;
291
329
  this.storage = storage;
292
330
  this.name = name;
@@ -301,8 +339,6 @@ export abstract class CoreApp<
301
339
  // Device id
302
340
  this._deviceId = storage.getData(this.fields.deviceId, '');
303
341
 
304
- this.setApi(api);
305
-
306
342
  const { currentCulture, currentRegion } = settings;
307
343
 
308
344
  // Load resources
@@ -464,19 +500,86 @@ export abstract class CoreApp<
464
500
  this.storage.copyTo(this.persistedFields);
465
501
  }
466
502
 
503
+ /**
504
+ * Add scheduled task
505
+ * @param task Task, return false to stop
506
+ * @param seconds Interval in seconds
507
+ */
508
+ addTask(task: () => PromiseLike<void | false>, seconds: number) {
509
+ this.tasks.push([task, seconds, seconds]);
510
+ }
511
+
512
+ /**
513
+ * Create API client, override to implement custom client creation by name
514
+ * @param name Client name
515
+ * @param item External endpoint item
516
+ * @returns Result
517
+ */
518
+ createApi(
519
+ name: string,
520
+ item: ExternalEndpoint,
521
+ refresh?: (
522
+ api: IApi,
523
+ token: string
524
+ ) => Promise<[string, number] | undefined>
525
+ ) {
526
+ if (this.apis[name] != null) {
527
+ throw new Error(`API ${name} already exists`);
528
+ }
529
+
530
+ const api = createClient();
531
+ api.name = name;
532
+ api.baseUrl = item.endpoint;
533
+ this.setApi(api, refresh);
534
+ return api;
535
+ }
536
+
537
+ /**
538
+ * Update API token and expires
539
+ * @param name Api name
540
+ * @param token Refresh token
541
+ * @param seconds Access token expires in seconds
542
+ */
543
+ updateApi(name: string, token: string | undefined, seconds: number): void;
544
+ updateApi(
545
+ data: ApiTaskData,
546
+ token: string | undefined,
547
+ seconds: number
548
+ ): void;
549
+ updateApi(
550
+ nameOrData: string | ApiTaskData,
551
+ token: string | undefined,
552
+ seconds: number
553
+ ) {
554
+ const api =
555
+ typeof nameOrData === 'string' ? this.apis[nameOrData] : nameOrData;
556
+ if (api == null) return;
557
+
558
+ // Consider the API call delay
559
+ if (seconds > 0) {
560
+ seconds -= 30;
561
+ if (seconds < 10) seconds = 10;
562
+ }
563
+
564
+ api[1] = seconds;
565
+ api[2] = seconds;
566
+ api[4] = token;
567
+ }
568
+
467
569
  /**
468
570
  * Setup Api
469
571
  * @param api Api
470
572
  */
471
- protected setApi(api: IApi) {
472
- // Base URL of the API
473
- api.baseUrl = this.settings.endpoint;
474
-
573
+ protected setApi(api: IApi, refresh?: ApiRefreshTokenFunction) {
475
574
  // onRequest, show loading or not, rewrite the property to override default action
476
575
  this.setApiLoading(api);
477
576
 
478
577
  // Global API error handler
479
578
  this.setApiErrorHandler(api);
579
+
580
+ // Setup API countdown
581
+ refresh ??= this.apiRefreshToken.bind(this);
582
+ this.apis[api.name] = [api, -1, -1, refresh];
480
583
  }
481
584
 
482
585
  /**
@@ -484,7 +587,7 @@ export abstract class CoreApp<
484
587
  * @param api Api
485
588
  * @param handlerFor401 Handler for 401 error
486
589
  */
487
- public setApiErrorHandler(
590
+ setApiErrorHandler(
488
591
  api: IApi,
489
592
  handlerFor401?: boolean | (() => Promise<void>)
490
593
  ) {
@@ -492,7 +595,7 @@ export abstract class CoreApp<
492
595
  // Debug
493
596
  if (this.debug) {
494
597
  console.debug(
495
- 'CoreApp.setApiErrorHandler',
598
+ `CoreApp.${this.name}.setApiErrorHandler`,
496
599
  api,
497
600
  error,
498
601
  handlerFor401
@@ -519,11 +622,13 @@ export abstract class CoreApp<
519
622
  error.message === 'Failed to fetch')
520
623
  ) {
521
624
  // Network error
522
- this.notifier.alert(this.get('networkError')!);
625
+ this.notifier.alert(
626
+ this.get('networkError') + ` [${this.name}]`
627
+ );
523
628
  return;
524
629
  } else {
525
630
  // Log
526
- console.error('API error', error);
631
+ console.error(`${this.name} API error`, error);
527
632
  }
528
633
 
529
634
  // Report the error
@@ -535,13 +640,13 @@ export abstract class CoreApp<
535
640
  * Setup Api loading
536
641
  * @param api Api
537
642
  */
538
- public setApiLoading(api: IApi) {
643
+ setApiLoading(api: IApi) {
539
644
  // onRequest, show loading or not, rewrite the property to override default action
540
645
  api.onRequest = (data) => {
541
646
  // Debug
542
647
  if (this.debug) {
543
648
  console.debug(
544
- 'CoreApp.setApiLoading.onRequest',
649
+ `CoreApp.${this.name}.setApiLoading.onRequest`,
545
650
  api,
546
651
  data,
547
652
  this.notifier.loadingCount
@@ -558,7 +663,7 @@ export abstract class CoreApp<
558
663
  // Debug
559
664
  if (this.debug) {
560
665
  console.debug(
561
- 'CoreApp.setApiLoading.onComplete',
666
+ `CoreApp.${this.name}.setApiLoading.onComplete`,
562
667
  api,
563
668
  data,
564
669
  this.notifier.loadingCount,
@@ -572,7 +677,7 @@ export abstract class CoreApp<
572
677
  // Debug
573
678
  if (this.debug) {
574
679
  console.debug(
575
- 'CoreApp.setApiLoading.onComplete.showLoading',
680
+ `CoreApp.${this.name}.setApiLoading.onComplete.showLoading`,
576
681
  api,
577
682
  this.notifier.loadingCount
578
683
  );
@@ -587,7 +692,7 @@ export abstract class CoreApp<
587
692
  * @param action Custom action
588
693
  * @param preventDefault Is prevent default action
589
694
  */
590
- public setupLogging(
695
+ setupLogging(
591
696
  action?: (data: ErrorData) => void | Promise<void>,
592
697
  preventDefault?: ((type: ErrorType) => boolean) | boolean
593
698
  ) {
@@ -869,10 +974,18 @@ export abstract class CoreApp<
869
974
  this._isTryingLogin = false;
870
975
 
871
976
  // Token countdown
872
- if (this.authorized) this.refreshCountdown(this.userData!.seconds);
873
- else {
977
+ if (this.authorized) {
978
+ this.lastCalled = false;
979
+ if (refreshToken) {
980
+ this.updateApi(
981
+ this.api.name,
982
+ refreshToken,
983
+ this.userData!.seconds
984
+ );
985
+ }
986
+ } else {
874
987
  this.cachedRefreshToken = undefined;
875
- this.refreshCountdownClear();
988
+ this.updateApi(this.api.name, undefined, -1);
876
989
  }
877
990
 
878
991
  // Host notice
@@ -1766,43 +1879,6 @@ export abstract class CoreApp<
1766
1879
  this.notifier.hideLoading(true);
1767
1880
  }
1768
1881
 
1769
- /**
1770
- * Refresh countdown
1771
- * @param seconds Seconds
1772
- */
1773
- protected refreshCountdown(seconds: number) {
1774
- // Make sure is big than 60 seconds
1775
- // Take action 60 seconds before expiry
1776
- seconds -= 60;
1777
- if (seconds <= 0) return;
1778
-
1779
- // Clear the current timeout seed
1780
- this.refreshCountdownClear();
1781
-
1782
- // Reset last call flag
1783
- // Any success call will update it to true
1784
- // So first time after login will be always silent
1785
- this.lastCalled = false;
1786
-
1787
- this.refreshCountdownSeed = window.setTimeout(() => {
1788
- if (this.lastCalled) {
1789
- // Call refreshToken to update access token
1790
- this.refreshToken();
1791
- } else {
1792
- // Popup countdown for user action
1793
- this.freshCountdownUI();
1794
- }
1795
- }, 1000 * seconds);
1796
- }
1797
-
1798
- protected refreshCountdownClear() {
1799
- // Clear the current timeout seed
1800
- if (this.refreshCountdownSeed > 0) {
1801
- window.clearTimeout(this.refreshCountdownSeed);
1802
- this.refreshCountdownSeed = 0;
1803
- }
1804
- }
1805
-
1806
1882
  /**
1807
1883
  * Fresh countdown UI
1808
1884
  * @param callback Callback
@@ -1822,6 +1898,9 @@ export abstract class CoreApp<
1822
1898
  * Setup callback
1823
1899
  */
1824
1900
  setup() {
1901
+ // Done already
1902
+ if (this.isReady) return;
1903
+
1825
1904
  // Ready
1826
1905
  this.isReady = true;
1827
1906
 
@@ -1830,6 +1909,167 @@ export abstract class CoreApp<
1830
1909
 
1831
1910
  // Pending actions
1832
1911
  this.pendings.forEach((p) => p());
1912
+
1913
+ // Setup scheduled tasks
1914
+ this.setupTasks();
1915
+ }
1916
+
1917
+ /**
1918
+ * Exchange token data
1919
+ * @param api API
1920
+ * @param token Core system's refresh token to exchange
1921
+ * @returns Result
1922
+ */
1923
+ async exchangeToken(api: IApi, token: string) {
1924
+ // Call the API quietly, no loading bar and no error popup
1925
+ const data = await api.put<ApiRefreshTokenDto>(
1926
+ 'Auth/ExchangeToken',
1927
+ {
1928
+ token
1929
+ },
1930
+ {
1931
+ showLoading: false,
1932
+ onError: (error) => {
1933
+ console.error(
1934
+ `CoreApp.${api.name}.ExchangeToken error`,
1935
+ error
1936
+ );
1937
+
1938
+ // Prevent further processing
1939
+ return false;
1940
+ }
1941
+ }
1942
+ );
1943
+
1944
+ if (data) {
1945
+ // Update the access token
1946
+ api.authorize(data.tokenType, data.accessToken);
1947
+
1948
+ // Update the API
1949
+ this.updateApi(api.name, data.refreshToken, data.expiresIn);
1950
+
1951
+ // Update notice
1952
+ this.exchangeTokenUpdate(api, data);
1953
+ }
1954
+ }
1955
+
1956
+ /**
1957
+ * Exchange token update, override to get the new token
1958
+ * @param api API
1959
+ * @param data API refresh token data
1960
+ */
1961
+ protected exchangeTokenUpdate(api: IApi, data: ApiRefreshTokenDto) {}
1962
+
1963
+ /**
1964
+ * Exchange intergration tokens for all APIs
1965
+ * @param token Core system's refresh token to exchange
1966
+ */
1967
+ exchangeTokenAll(token: string) {
1968
+ for (const name in this.apis) {
1969
+ const api = this.apis[name];
1970
+ this.exchangeToken(api[0], token);
1971
+ }
1972
+ }
1973
+
1974
+ /**
1975
+ * API refresh token
1976
+ * @param api Current API
1977
+ * @param token Refresh token
1978
+ * @returns Result
1979
+ */
1980
+ protected async apiRefreshToken(
1981
+ api: IApi,
1982
+ token: string
1983
+ ): Promise<[string, number] | undefined> {
1984
+ // Call the API quietly, no loading bar and no error popup
1985
+ const data = await api.put<ApiRefreshTokenDto>(
1986
+ 'Auth/ApiRefreshToken',
1987
+ {
1988
+ token
1989
+ },
1990
+ {
1991
+ showLoading: false,
1992
+ onError: (error) => {
1993
+ console.error(
1994
+ `CoreApp.${api.name}.apiRefreshToken error`,
1995
+ error
1996
+ );
1997
+
1998
+ // Prevent further processing
1999
+ return false;
2000
+ }
2001
+ }
2002
+ );
2003
+
2004
+ if (data == null) return undefined;
2005
+
2006
+ // Update the access token
2007
+ api.authorize(data.tokenType, data.accessToken);
2008
+
2009
+ // Return the new refresh token and access token expiration seconds
2010
+ return [data.refreshToken, data.expiresIn];
2011
+ }
2012
+
2013
+ /**
2014
+ * Setup tasks
2015
+ */
2016
+ protected setupTasks() {
2017
+ ExtendUtils.intervalFor(() => {
2018
+ // Exit when not authorized
2019
+ if (!this.authorized) return;
2020
+
2021
+ // APIs
2022
+ for (const name in this.apis) {
2023
+ // Get the API
2024
+ const api = this.apis[name];
2025
+
2026
+ // Skip the negative value or when refresh token is not set
2027
+ if (!api[4] || api[2] < 0) continue;
2028
+
2029
+ // Minus one second
2030
+ api[2] -= 1;
2031
+
2032
+ // Ready to trigger
2033
+ if (api[2] === 0) {
2034
+ // Refresh token
2035
+ api[3](api[0], api[4]).then((data) => {
2036
+ if (data == null) {
2037
+ // Failed, try it again in 2 seconds
2038
+ api[2] = 2;
2039
+ } else {
2040
+ // Reset the API
2041
+ const [token, seconds] = data;
2042
+ this.updateApi(api, token, seconds);
2043
+ }
2044
+ });
2045
+ }
2046
+ }
2047
+
2048
+ for (let t = this.tasks.length - 1; t >= 0; t--) {
2049
+ // Get the task
2050
+ const task = this.tasks[t];
2051
+
2052
+ // Minus one second
2053
+ task[2] -= 1;
2054
+
2055
+ // Remove the tasks with negative value with splice
2056
+ if (task[2] < 0) {
2057
+ this.tasks.splice(t, 1);
2058
+ } else if (task[2] === 0) {
2059
+ // Ready to trigger
2060
+ // Reset the task
2061
+ task[2] = task[1];
2062
+
2063
+ // Trigger the task
2064
+ task[0]().then((result) => {
2065
+ if (result === false) {
2066
+ // Asynchronous task, unsafe to splice the index, flag as pending
2067
+ task[2] = -1;
2068
+ }
2069
+ });
2070
+ }
2071
+ }
2072
+ }, 1000);
1833
2073
  }
1834
2074
 
1835
2075
  /**
@@ -1872,10 +2112,9 @@ export abstract class CoreApp<
1872
2112
  /**
1873
2113
  * Try login, returning false means is loading
1874
2114
  * UI get involved while refreshToken not intended
1875
- * @param data Additional request data
1876
2115
  * @param showLoading Show loading bar or not during call
1877
2116
  */
1878
- async tryLogin<D extends object = {}>(_data?: D, _showLoading?: boolean) {
2117
+ async tryLogin(_showLoading?: boolean) {
1879
2118
  if (this._isTryingLogin) return false;
1880
2119
  this._isTryingLogin = true;
1881
2120
  return true;
@@ -1,36 +1,39 @@
1
1
  /**
2
- * External settings items
2
+ * External endpoint
3
3
  */
4
- export interface IExternalSettings {
4
+ export type ExternalEndpoint = {
5
5
  /**
6
- * Core system API endpoint
6
+ * API endpoint
7
7
  */
8
8
  readonly endpoint: string;
9
9
 
10
10
  /**
11
- * Message hub endpoint
12
- */
13
- readonly messageHub?: string;
14
-
15
- /**
16
- * Core system app root url
11
+ * Web url
17
12
  */
18
- readonly homepage: string;
13
+ readonly webUrl: string;
14
+ };
19
15
 
16
+ /**
17
+ * External settings items
18
+ */
19
+ export interface IExternalSettings extends ExternalEndpoint {
20
20
  /**
21
- * Core system web url
21
+ * Message hub endpoint
22
22
  */
23
- readonly webUrl: string;
23
+ readonly messageHub?: string;
24
24
 
25
25
  /**
26
- * Service API endpoint
26
+ * App root url
27
27
  */
28
- readonly serviceEndpoint?: string;
28
+ readonly homepage: string;
29
29
 
30
30
  /**
31
- * Service web Url
31
+ * Endpoints to other services
32
32
  */
33
- readonly serviceUrl?: string;
33
+ readonly endpoints?: Record<
34
+ 'core' | 'accounting' | 'crm' | 'calandar' | 'task' | string,
35
+ ExternalEndpoint
36
+ >;
34
37
  }
35
38
 
36
39
  /**
@@ -69,6 +72,8 @@ export namespace ExternalSettings {
69
72
  const value = settings[key];
70
73
  if (typeof value === 'string') {
71
74
  settings[key] = value.replace('{hostname}', hostname);
75
+ } else if (typeof value === 'object') {
76
+ format(value, hostname);
72
77
  }
73
78
  }
74
79