@etsoo/appscript 1.5.20 → 1.5.22

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.
@@ -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
  */
@@ -222,6 +234,16 @@ export abstract class CoreApp<
222
234
  this._isReady = value;
223
235
  }
224
236
 
237
+ /**
238
+ * Current cached URL
239
+ */
240
+ get cachedUrl() {
241
+ return this.storage.getData(this.fields.cachedUrl);
242
+ }
243
+ set cachedUrl(value: string | undefined | null) {
244
+ this.storage.setData(this.fields.cachedUrl, value);
245
+ }
246
+
225
247
  private _isTryingLogin = false;
226
248
 
227
249
  /**
@@ -229,11 +251,6 @@ export abstract class CoreApp<
229
251
  */
230
252
  protected lastCalled = false;
231
253
 
232
- /**
233
- * Token refresh count down seed
234
- */
235
- protected refreshCountdownSeed = 0;
236
-
237
254
  /**
238
255
  * Init call Api URL
239
256
  */
@@ -246,6 +263,10 @@ export abstract class CoreApp<
246
263
 
247
264
  private cachedRefreshToken?: string;
248
265
 
266
+ private apis: Record<string, ApiTaskData> = {};
267
+
268
+ private tasks: [() => PromiseLike<void | false>, number, number][] = [];
269
+
249
270
  /**
250
271
  * Get persisted fields
251
272
  */
@@ -269,7 +290,7 @@ export abstract class CoreApp<
269
290
  */
270
291
  protected constructor(
271
292
  settings: S,
272
- api: IApi,
293
+ api: IApi | undefined | null,
273
294
  notifier: INotifier<N, C>,
274
295
  storage: IStorage,
275
296
  name: string,
@@ -286,7 +307,34 @@ export abstract class CoreApp<
286
307
  }
287
308
  this.defaultRegion = region;
288
309
 
289
- this.api = api;
310
+ const refresh: ApiRefreshTokenFunction = async (api, token) => {
311
+ if (this.lastCalled) {
312
+ // Call refreshToken to update access token
313
+ await this.refreshToken();
314
+ } else {
315
+ // Popup countdown for user action
316
+ this.freshCountdownUI();
317
+ }
318
+ return undefined;
319
+ };
320
+
321
+ if (api) {
322
+ // Base URL of the API
323
+ api.baseUrl = this.settings.endpoint;
324
+ api.name = 'system';
325
+ this.setApi(api, refresh);
326
+ this.api = api;
327
+ } else {
328
+ this.api = this.createApi(
329
+ 'system',
330
+ {
331
+ endpoint: settings.endpoint,
332
+ webUrl: settings.webUrl
333
+ },
334
+ refresh
335
+ );
336
+ }
337
+
290
338
  this.notifier = notifier;
291
339
  this.storage = storage;
292
340
  this.name = name;
@@ -301,8 +349,6 @@ export abstract class CoreApp<
301
349
  // Device id
302
350
  this._deviceId = storage.getData(this.fields.deviceId, '');
303
351
 
304
- this.setApi(api);
305
-
306
352
  const { currentCulture, currentRegion } = settings;
307
353
 
308
354
  // Load resources
@@ -464,19 +510,86 @@ export abstract class CoreApp<
464
510
  this.storage.copyTo(this.persistedFields);
465
511
  }
466
512
 
513
+ /**
514
+ * Add scheduled task
515
+ * @param task Task, return false to stop
516
+ * @param seconds Interval in seconds
517
+ */
518
+ addTask(task: () => PromiseLike<void | false>, seconds: number) {
519
+ this.tasks.push([task, seconds, seconds]);
520
+ }
521
+
522
+ /**
523
+ * Create API client, override to implement custom client creation by name
524
+ * @param name Client name
525
+ * @param item External endpoint item
526
+ * @returns Result
527
+ */
528
+ createApi(
529
+ name: string,
530
+ item: ExternalEndpoint,
531
+ refresh?: (
532
+ api: IApi,
533
+ token: string
534
+ ) => Promise<[string, number] | undefined>
535
+ ) {
536
+ if (this.apis[name] != null) {
537
+ throw new Error(`API ${name} already exists`);
538
+ }
539
+
540
+ const api = createClient();
541
+ api.name = name;
542
+ api.baseUrl = item.endpoint;
543
+ this.setApi(api, refresh);
544
+ return api;
545
+ }
546
+
547
+ /**
548
+ * Update API token and expires
549
+ * @param name Api name
550
+ * @param token Refresh token
551
+ * @param seconds Access token expires in seconds
552
+ */
553
+ updateApi(name: string, token: string | undefined, seconds: number): void;
554
+ updateApi(
555
+ data: ApiTaskData,
556
+ token: string | undefined,
557
+ seconds: number
558
+ ): void;
559
+ updateApi(
560
+ nameOrData: string | ApiTaskData,
561
+ token: string | undefined,
562
+ seconds: number
563
+ ) {
564
+ const api =
565
+ typeof nameOrData === 'string' ? this.apis[nameOrData] : nameOrData;
566
+ if (api == null) return;
567
+
568
+ // Consider the API call delay
569
+ if (seconds > 0) {
570
+ seconds -= 30;
571
+ if (seconds < 10) seconds = 10;
572
+ }
573
+
574
+ api[1] = seconds;
575
+ api[2] = seconds;
576
+ api[4] = token;
577
+ }
578
+
467
579
  /**
468
580
  * Setup Api
469
581
  * @param api Api
470
582
  */
471
- protected setApi(api: IApi) {
472
- // Base URL of the API
473
- api.baseUrl = this.settings.endpoint;
474
-
583
+ protected setApi(api: IApi, refresh?: ApiRefreshTokenFunction) {
475
584
  // onRequest, show loading or not, rewrite the property to override default action
476
585
  this.setApiLoading(api);
477
586
 
478
587
  // Global API error handler
479
588
  this.setApiErrorHandler(api);
589
+
590
+ // Setup API countdown
591
+ refresh ??= this.apiRefreshToken.bind(this);
592
+ this.apis[api.name] = [api, -1, -1, refresh];
480
593
  }
481
594
 
482
595
  /**
@@ -484,7 +597,7 @@ export abstract class CoreApp<
484
597
  * @param api Api
485
598
  * @param handlerFor401 Handler for 401 error
486
599
  */
487
- public setApiErrorHandler(
600
+ setApiErrorHandler(
488
601
  api: IApi,
489
602
  handlerFor401?: boolean | (() => Promise<void>)
490
603
  ) {
@@ -492,7 +605,7 @@ export abstract class CoreApp<
492
605
  // Debug
493
606
  if (this.debug) {
494
607
  console.debug(
495
- 'CoreApp.setApiErrorHandler',
608
+ `CoreApp.${this.name}.setApiErrorHandler`,
496
609
  api,
497
610
  error,
498
611
  handlerFor401
@@ -519,11 +632,13 @@ export abstract class CoreApp<
519
632
  error.message === 'Failed to fetch')
520
633
  ) {
521
634
  // Network error
522
- this.notifier.alert(this.get('networkError')!);
635
+ this.notifier.alert(
636
+ this.get('networkError') + ` [${this.name}]`
637
+ );
523
638
  return;
524
639
  } else {
525
640
  // Log
526
- console.error('API error', error);
641
+ console.error(`${this.name} API error`, error);
527
642
  }
528
643
 
529
644
  // Report the error
@@ -535,13 +650,13 @@ export abstract class CoreApp<
535
650
  * Setup Api loading
536
651
  * @param api Api
537
652
  */
538
- public setApiLoading(api: IApi) {
653
+ setApiLoading(api: IApi) {
539
654
  // onRequest, show loading or not, rewrite the property to override default action
540
655
  api.onRequest = (data) => {
541
656
  // Debug
542
657
  if (this.debug) {
543
658
  console.debug(
544
- 'CoreApp.setApiLoading.onRequest',
659
+ `CoreApp.${this.name}.setApiLoading.onRequest`,
545
660
  api,
546
661
  data,
547
662
  this.notifier.loadingCount
@@ -558,7 +673,7 @@ export abstract class CoreApp<
558
673
  // Debug
559
674
  if (this.debug) {
560
675
  console.debug(
561
- 'CoreApp.setApiLoading.onComplete',
676
+ `CoreApp.${this.name}.setApiLoading.onComplete`,
562
677
  api,
563
678
  data,
564
679
  this.notifier.loadingCount,
@@ -572,7 +687,7 @@ export abstract class CoreApp<
572
687
  // Debug
573
688
  if (this.debug) {
574
689
  console.debug(
575
- 'CoreApp.setApiLoading.onComplete.showLoading',
690
+ `CoreApp.${this.name}.setApiLoading.onComplete.showLoading`,
576
691
  api,
577
692
  this.notifier.loadingCount
578
693
  );
@@ -587,7 +702,7 @@ export abstract class CoreApp<
587
702
  * @param action Custom action
588
703
  * @param preventDefault Is prevent default action
589
704
  */
590
- public setupLogging(
705
+ setupLogging(
591
706
  action?: (data: ErrorData) => void | Promise<void>,
592
707
  preventDefault?: ((type: ErrorType) => boolean) | boolean
593
708
  ) {
@@ -869,10 +984,18 @@ export abstract class CoreApp<
869
984
  this._isTryingLogin = false;
870
985
 
871
986
  // Token countdown
872
- if (this.authorized) this.refreshCountdown(this.userData!.seconds);
873
- else {
987
+ if (this.authorized) {
988
+ this.lastCalled = false;
989
+ if (refreshToken) {
990
+ this.updateApi(
991
+ this.api.name,
992
+ refreshToken,
993
+ this.userData!.seconds
994
+ );
995
+ }
996
+ } else {
874
997
  this.cachedRefreshToken = undefined;
875
- this.refreshCountdownClear();
998
+ this.updateApi(this.api.name, undefined, -1);
876
999
  }
877
1000
 
878
1001
  // Host notice
@@ -1766,43 +1889,6 @@ export abstract class CoreApp<
1766
1889
  this.notifier.hideLoading(true);
1767
1890
  }
1768
1891
 
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
1892
  /**
1807
1893
  * Fresh countdown UI
1808
1894
  * @param callback Callback
@@ -1822,6 +1908,9 @@ export abstract class CoreApp<
1822
1908
  * Setup callback
1823
1909
  */
1824
1910
  setup() {
1911
+ // Done already
1912
+ if (this.isReady) return;
1913
+
1825
1914
  // Ready
1826
1915
  this.isReady = true;
1827
1916
 
@@ -1830,6 +1919,167 @@ export abstract class CoreApp<
1830
1919
 
1831
1920
  // Pending actions
1832
1921
  this.pendings.forEach((p) => p());
1922
+
1923
+ // Setup scheduled tasks
1924
+ this.setupTasks();
1925
+ }
1926
+
1927
+ /**
1928
+ * Exchange token data
1929
+ * @param api API
1930
+ * @param token Core system's refresh token to exchange
1931
+ * @returns Result
1932
+ */
1933
+ async exchangeToken(api: IApi, token: string) {
1934
+ // Call the API quietly, no loading bar and no error popup
1935
+ const data = await api.put<ApiRefreshTokenDto>(
1936
+ 'Auth/ExchangeToken',
1937
+ {
1938
+ token
1939
+ },
1940
+ {
1941
+ showLoading: false,
1942
+ onError: (error) => {
1943
+ console.error(
1944
+ `CoreApp.${api.name}.ExchangeToken error`,
1945
+ error
1946
+ );
1947
+
1948
+ // Prevent further processing
1949
+ return false;
1950
+ }
1951
+ }
1952
+ );
1953
+
1954
+ if (data) {
1955
+ // Update the access token
1956
+ api.authorize(data.tokenType, data.accessToken);
1957
+
1958
+ // Update the API
1959
+ this.updateApi(api.name, data.refreshToken, data.expiresIn);
1960
+
1961
+ // Update notice
1962
+ this.exchangeTokenUpdate(api, data);
1963
+ }
1964
+ }
1965
+
1966
+ /**
1967
+ * Exchange token update, override to get the new token
1968
+ * @param api API
1969
+ * @param data API refresh token data
1970
+ */
1971
+ protected exchangeTokenUpdate(api: IApi, data: ApiRefreshTokenDto) {}
1972
+
1973
+ /**
1974
+ * Exchange intergration tokens for all APIs
1975
+ * @param token Core system's refresh token to exchange
1976
+ */
1977
+ exchangeTokenAll(token: string) {
1978
+ for (const name in this.apis) {
1979
+ const api = this.apis[name];
1980
+ this.exchangeToken(api[0], token);
1981
+ }
1982
+ }
1983
+
1984
+ /**
1985
+ * API refresh token
1986
+ * @param api Current API
1987
+ * @param token Refresh token
1988
+ * @returns Result
1989
+ */
1990
+ protected async apiRefreshToken(
1991
+ api: IApi,
1992
+ token: string
1993
+ ): Promise<[string, number] | undefined> {
1994
+ // Call the API quietly, no loading bar and no error popup
1995
+ const data = await api.put<ApiRefreshTokenDto>(
1996
+ 'Auth/ApiRefreshToken',
1997
+ {
1998
+ token
1999
+ },
2000
+ {
2001
+ showLoading: false,
2002
+ onError: (error) => {
2003
+ console.error(
2004
+ `CoreApp.${api.name}.apiRefreshToken error`,
2005
+ error
2006
+ );
2007
+
2008
+ // Prevent further processing
2009
+ return false;
2010
+ }
2011
+ }
2012
+ );
2013
+
2014
+ if (data == null) return undefined;
2015
+
2016
+ // Update the access token
2017
+ api.authorize(data.tokenType, data.accessToken);
2018
+
2019
+ // Return the new refresh token and access token expiration seconds
2020
+ return [data.refreshToken, data.expiresIn];
2021
+ }
2022
+
2023
+ /**
2024
+ * Setup tasks
2025
+ */
2026
+ protected setupTasks() {
2027
+ ExtendUtils.intervalFor(() => {
2028
+ // Exit when not authorized
2029
+ if (!this.authorized) return;
2030
+
2031
+ // APIs
2032
+ for (const name in this.apis) {
2033
+ // Get the API
2034
+ const api = this.apis[name];
2035
+
2036
+ // Skip the negative value or when refresh token is not set
2037
+ if (!api[4] || api[2] < 0) continue;
2038
+
2039
+ // Minus one second
2040
+ api[2] -= 1;
2041
+
2042
+ // Ready to trigger
2043
+ if (api[2] === 0) {
2044
+ // Refresh token
2045
+ api[3](api[0], api[4]).then((data) => {
2046
+ if (data == null) {
2047
+ // Failed, try it again in 2 seconds
2048
+ api[2] = 2;
2049
+ } else {
2050
+ // Reset the API
2051
+ const [token, seconds] = data;
2052
+ this.updateApi(api, token, seconds);
2053
+ }
2054
+ });
2055
+ }
2056
+ }
2057
+
2058
+ for (let t = this.tasks.length - 1; t >= 0; t--) {
2059
+ // Get the task
2060
+ const task = this.tasks[t];
2061
+
2062
+ // Minus one second
2063
+ task[2] -= 1;
2064
+
2065
+ // Remove the tasks with negative value with splice
2066
+ if (task[2] < 0) {
2067
+ this.tasks.splice(t, 1);
2068
+ } else if (task[2] === 0) {
2069
+ // Ready to trigger
2070
+ // Reset the task
2071
+ task[2] = task[1];
2072
+
2073
+ // Trigger the task
2074
+ task[0]().then((result) => {
2075
+ if (result === false) {
2076
+ // Asynchronous task, unsafe to splice the index, flag as pending
2077
+ task[2] = -1;
2078
+ }
2079
+ });
2080
+ }
2081
+ }
2082
+ }, 1000);
1833
2083
  }
1834
2084
 
1835
2085
  /**
@@ -1862,9 +2112,10 @@ export abstract class CoreApp<
1862
2112
  * @param removeUrl Remove current URL for reuse
1863
2113
  */
1864
2114
  toLoginPage(tryLogin?: boolean, removeUrl?: boolean) {
1865
- const url =
1866
- `/?tryLogin=${tryLogin ?? false}` +
1867
- (removeUrl ? '' : '&url=' + encodeURIComponent(location.href));
2115
+ // Save the current URL
2116
+ this.cachedUrl = removeUrl ? undefined : globalThis.location.href;
2117
+
2118
+ const url = `/?tryLogin=${tryLogin ?? false}`;
1868
2119
 
1869
2120
  this.navigate(url);
1870
2121
  }
@@ -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