asab_webui_shell 27.7.5-alpha.0 → 27.8.1

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.
@@ -12,6 +12,7 @@ var _axios = _interopRequireDefault(require("axios"));
12
12
  var _asab_webui_components = require("asab_webui_components");
13
13
  var _jsonParseWithBigInt2 = require("../utils/jsonParseWithBigInt");
14
14
  var _statusAlerts = require("../utils/statusAlerts.js");
15
+ var _pageLifecycle = require("../utils/pageLifecycle");
15
16
  var _Header = _interopRequireDefault(require("./Header"));
16
17
  var _Sidebar = _interopRequireDefault(require("./Sidebar"));
17
18
  var _ToastContainer = _interopRequireDefault(require("./Toast/ToastContainer.js"));
@@ -34,6 +35,7 @@ var _BrandingService = _interopRequireDefault(require("../services/BrandingServi
34
35
  var _TitleService = _interopRequireDefault(require("../services/TitleService"));
35
36
  var _HelpService = _interopRequireDefault(require("./Header/Help/HelpService"));
36
37
  var _AccessDeniedCard = _interopRequireDefault(require("../modules/tenant/access/AccessDeniedCard"));
38
+ var _LoginLoopCard = require("../modules/auth/components/LoginLoopCard");
37
39
  var _ApplicationRouter = _interopRequireDefault(require("./Router/ApplicationRouter"));
38
40
  var _SuspenseScreen = _interopRequireDefault(require("../screens/SuspenseScreen"));
39
41
  var _OfflineIndication = require("../modules/attentionrequired/components/OfflineIndication.js");
@@ -65,6 +67,7 @@ class Application extends _react.Component {
65
67
  this.Navigation = new _Navigation.default(this);
66
68
  this.SplashscreenRequestors = new Set(); // If not empty, the splash screen will be rendered
67
69
  this.AxiosInterceptors = new Set();
70
+ this.AxiosResponseErrorInterceptors = new Set(); // Interceptors for handling error responses from the server (e.g 401)
68
71
  this.WebSocketInterceptors = new Set();
69
72
  this.HeaderService = new _HeaderService.default(this, "HeaderService");
70
73
  this.ThemeService = new _ThemeService.default(this, "ThemeService");
@@ -106,6 +109,7 @@ class Application extends _react.Component {
106
109
  // Subscribe and unsubscribe handlers for connectivity detection
107
110
  this._initConnectivitySubscription = this._initConnectivitySubscription.bind(this);
108
111
  this._unsubscribeConnectivity = null;
112
+ this._unsubscribePageHide = null;
109
113
  this.ConfigService.addDefaults(props.configdefaults);
110
114
  this.addSplashScreenRequestor(this);
111
115
  this.state.splashscreenRequestors = this.SplashscreenRequestors.size;
@@ -360,23 +364,37 @@ class Application extends _react.Component {
360
364
  }
361
365
  // If the request was satisfied (application/json and presence of BigInt) return the modified object. If not, we return unchanged object
362
366
  return response;
363
- }, function (error) {
364
- var _error$config, _error$response, _error$response2;
365
- if (!((_error$config = error.config) !== null && _error$config !== void 0 && _error$config._networkingIndicatorOff)) {
366
- that.popNetworkingIndicator();
367
- }
368
- that.popPrintReadyIndicator();
369
- var contentType = error === null || error === void 0 || (_error$response = error.response) === null || _error$response === void 0 || (_error$response = _error$response.headers) === null || _error$response === void 0 ? void 0 : _error$response['content-type'];
370
- // Check if the response content type is 'application/json' and data is a string
371
- if (contentType !== null && contentType !== void 0 && contentType.startsWith('application/json') && typeof (error === null || error === void 0 || (_error$response2 = error.response) === null || _error$response2 === void 0 ? void 0 : _error$response2.data) === 'string') {
372
- try {
373
- error.response.data = JSON.parse(error.response.data);
374
- } catch (e) {
375
- console.error("Error parsing error of the error body:", e);
367
+ }, /*#__PURE__*/function () {
368
+ var _ref2 = (0, _asyncToGenerator2.default)(function* (error) {
369
+ var _error$config, _error$response, _error$response2;
370
+ if (!((_error$config = error.config) !== null && _error$config !== void 0 && _error$config._networkingIndicatorOff)) {
371
+ that.popNetworkingIndicator();
376
372
  }
377
- }
378
- return Promise.reject(error);
379
- });
373
+ that.popPrintReadyIndicator();
374
+ var contentType = error === null || error === void 0 || (_error$response = error.response) === null || _error$response === void 0 || (_error$response = _error$response.headers) === null || _error$response === void 0 ? void 0 : _error$response['content-type'];
375
+ // Check if the response content type is 'application/json' and data is a string
376
+ if (contentType !== null && contentType !== void 0 && contentType.startsWith('application/json') && typeof (error === null || error === void 0 || (_error$response2 = error.response) === null || _error$response2 === void 0 ? void 0 : _error$response2.data) === 'string') {
377
+ try {
378
+ error.response.data = JSON.parse(error.response.data);
379
+ } catch (e) {
380
+ console.error("Error parsing error of the error body:", e);
381
+ }
382
+ }
383
+
384
+ // Call registered response error interceptors (e.g. for 401 handling)
385
+ for (var _interceptor of that.AxiosResponseErrorInterceptors.keys()) {
386
+ try {
387
+ yield _interceptor(error);
388
+ } catch (interceptorError) {
389
+ console.error("Error in AxiosResponseErrorInterceptors", interceptorError);
390
+ }
391
+ }
392
+ return Promise.reject(error);
393
+ });
394
+ return function (_x) {
395
+ return _ref2.apply(this, arguments);
396
+ };
397
+ }());
380
398
  return axios;
381
399
  }
382
400
 
@@ -401,6 +419,12 @@ class Application extends _react.Component {
401
419
  removeAxiosInterceptor(interceptor) {
402
420
  this.AxiosInterceptors.delete(interceptor);
403
421
  }
422
+ addAxiosResponseErrorInterceptor(interceptor) {
423
+ this.AxiosResponseErrorInterceptors.add(interceptor);
424
+ }
425
+ removeAxiosResponseErrorInterceptor(interceptor) {
426
+ this.AxiosResponseErrorInterceptors.delete(interceptor);
427
+ }
404
428
  addWebSocketInterceptor(interceptor) {
405
429
  this.WebSocketInterceptors.add(interceptor);
406
430
  }
@@ -520,6 +544,8 @@ class Application extends _react.Component {
520
544
 
521
545
  // Subscribe to Application.status! once PubSub is available
522
546
  this._initConnectivitySubscription();
547
+ // Bridge window page lifecycle events to Application.lifecycle!
548
+ this._initPageLifecycleBridge();
523
549
  // Add print-landscape class to body if not present
524
550
  if (!document.body.classList.contains('print-landscape')) {
525
551
  document.body.classList.add('print-landscape');
@@ -552,6 +578,12 @@ class Application extends _react.Component {
552
578
  this._unsubscribeConnectivity();
553
579
  this._unsubscribeConnectivity = null;
554
580
  }
581
+
582
+ // Unsubscribe from Application.lifecycle! PubSub for pagehide event
583
+ if (this._unsubscribePageHide) {
584
+ this._unsubscribePageHide();
585
+ this._unsubscribePageHide = null;
586
+ }
555
587
  this._clearOfflineIndicationTimeout();
556
588
  this._clearPrintReadyTimeout();
557
589
  document.body.removeAttribute('print-ready');
@@ -635,7 +667,7 @@ class Application extends _react.Component {
635
667
  addAlertFromException will always use "danger" color as its background and the full exception will be printed into the console
636
668
  */
637
669
  addAlertFromException(exception, message) {
638
- var _exception$response, _exception$response2, _this$AppStore$dispat3, _this$AppStore3;
670
+ var _exception$response, _this$AppStore2, _exception$response2, _this$AppStore$dispat3, _this$AppStore4;
639
671
  var expire = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 30;
640
672
  var shouldBeTranslated = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : false;
641
673
  var component = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : null;
@@ -646,11 +678,15 @@ class Application extends _react.Component {
646
678
  if ((exceptionStatus === 502 || exceptionStatus === 503 || exceptionStatus === 504) && this._indicateGatewayTimeout()) {
647
679
  return;
648
680
  }
681
+ // Skip 401 alert when session has expired
682
+ if (exceptionStatus === 401 && (_this$AppStore2 = this.AppStore) !== null && _this$AppStore2 !== void 0 && (_this$AppStore2 = _this$AppStore2.getState()) !== null && _this$AppStore2 !== void 0 && (_this$AppStore2 = _this$AppStore2.auth) !== null && _this$AppStore2 !== void 0 && _this$AppStore2.sessionExpired) {
683
+ return;
684
+ }
649
685
  // Handle specific response statuses and set the appropriate level and message
650
686
  var statusAlert = _statusAlerts.STATUS_ALERTS[exceptionStatus];
651
687
  if (statusAlert) {
652
- var _this$AppStore$dispat2, _this$AppStore2;
653
- (_this$AppStore$dispat2 = (_this$AppStore2 = this.AppStore).dispatch) === null || _this$AppStore$dispat2 === void 0 || _this$AppStore$dispat2.call(_this$AppStore2, {
688
+ var _this$AppStore$dispat2, _this$AppStore3;
689
+ (_this$AppStore$dispat2 = (_this$AppStore3 = this.AppStore).dispatch) === null || _this$AppStore$dispat2 === void 0 || _this$AppStore$dispat2.call(_this$AppStore3, {
654
690
  type: _actions.ADD_ALERT,
655
691
  level: statusAlert.level,
656
692
  message: statusAlert.message,
@@ -692,7 +728,7 @@ class Application extends _react.Component {
692
728
  }, exception.message));
693
729
  }
694
730
  }
695
- (_this$AppStore$dispat3 = (_this$AppStore3 = this.AppStore).dispatch) === null || _this$AppStore$dispat3 === void 0 || _this$AppStore$dispat3.call(_this$AppStore3, {
731
+ (_this$AppStore$dispat3 = (_this$AppStore4 = this.AppStore).dispatch) === null || _this$AppStore$dispat3 === void 0 || _this$AppStore$dispat3.call(_this$AppStore4, {
696
732
  type: _actions.ADD_ALERT,
697
733
  level: "danger",
698
734
  message: exceptionMessage,
@@ -736,12 +772,12 @@ class Application extends _react.Component {
736
772
  It takes a parameter called "status" to indicate whether to turn the full-screen mode on or off.
737
773
  */
738
774
  setFullScreenMode(status) {
739
- var _state$fullscreenmode, _this$AppStore$dispat4, _this$AppStore4;
775
+ var _state$fullscreenmode, _this$AppStore$dispat4, _this$AppStore5;
740
776
  var state = this.AppStore.getState();
741
777
  if (status === 'on' && (state === null || state === void 0 || (_state$fullscreenmode = state.fullscreenmode) === null || _state$fullscreenmode === void 0 ? void 0 : _state$fullscreenmode.status) === 'on') {
742
778
  status = 'off';
743
779
  }
744
- (_this$AppStore$dispat4 = (_this$AppStore4 = this.AppStore).dispatch) === null || _this$AppStore$dispat4 === void 0 || _this$AppStore$dispat4.call(_this$AppStore4, {
780
+ (_this$AppStore$dispat4 = (_this$AppStore5 = this.AppStore).dispatch) === null || _this$AppStore$dispat4 === void 0 || _this$AppStore$dispat4.call(_this$AppStore5, {
745
781
  type: _actions.SET_FULLSCREEN_MODE,
746
782
  status: status
747
783
  });
@@ -770,6 +806,8 @@ class Application extends _react.Component {
770
806
  id: "app-main"
771
807
  }, /*#__PURE__*/_react.default.createElement(_AccessDeniedCard.default, {
772
808
  app: this
809
+ }), /*#__PURE__*/_react.default.createElement(_LoginLoopCard.LoginLoopCard, {
810
+ app: this
773
811
  })))))
774
812
  );
775
813
  return /*#__PURE__*/_react.default.createElement(_asab_webui_components.AppStoreProvider, {
@@ -813,9 +851,9 @@ Application.prototype._initConnectivitySubscription = function () {
813
851
  if (this.PubSub && typeof this.PubSub.subscribe === 'function') {
814
852
  if (!this._unsubscribeConnectivity) {
815
853
  this._unsubscribeConnectivity = this.PubSub.subscribe('Application.status!', value => {
816
- var _this$AppStore$dispat5, _this$AppStore5;
854
+ var _this$AppStore$dispat5, _this$AppStore6;
817
855
  // Prefer store so UI updates predictably
818
- (_this$AppStore$dispat5 = (_this$AppStore5 = this.AppStore).dispatch) === null || _this$AppStore$dispat5 === void 0 || _this$AppStore$dispat5.call(_this$AppStore5, {
856
+ (_this$AppStore$dispat5 = (_this$AppStore6 = this.AppStore).dispatch) === null || _this$AppStore$dispat5 === void 0 || _this$AppStore$dispat5.call(_this$AppStore6, {
819
857
  type: _actions.SET_CONNECTIVITY_STATUS,
820
858
  status: value.status
821
859
  });
@@ -829,4 +867,18 @@ Application.prototype._initConnectivitySubscription = function () {
829
867
  */
830
868
  setTimeout(this._initConnectivitySubscription, 0);
831
869
  };
870
+
871
+ /*
872
+ On Application lifecycle events, bridge the events to PubSub topic Application.lifecycle!
873
+ */
874
+ Application.prototype._initPageLifecycleBridge = function () {
875
+ if (this._unsubscribePageHide) return;
876
+ this._unsubscribePageHide = (0, _pageLifecycle.subscribePageHide)(event => {
877
+ var _this$PubSub, _this$PubSub$publish;
878
+ (_this$PubSub = this.PubSub) === null || _this$PubSub === void 0 || (_this$PubSub$publish = _this$PubSub.publish) === null || _this$PubSub$publish === void 0 || _this$PubSub$publish.call(_this$PubSub, 'Application.lifecycle!', {
879
+ type: 'pagehide',
880
+ persisted: event.persisted
881
+ });
882
+ });
883
+ };
832
884
  var _default = exports.default = Application;
@@ -19,7 +19,21 @@ class Navigation extends _react.Component {
19
19
  path: '/some/path', // Url path
20
20
  end: true, // Whether path must be matched exactly
21
21
  name: 'Some Name', // Route name
22
- component: ReactComponent // Component to be rendered
22
+ openFor: ['/some/path/*'], // Optional array of routes to keep the item open
23
+ icon: 'bi bi-folder', // Icon
24
+ resource: 'some:resource:access', // Resource to check access for
25
+ order: 100, // Order of the item
26
+ children: [ // Optional array of children
27
+ {
28
+ path: '/some/path/child',
29
+ end: true,
30
+ name: 'Child Name',
31
+ openFor: ['/some/path/child'], // Optional array of routes to keep the child highlighted
32
+ icon: 'bi bi-file', // Icon
33
+ resource: 'some:resource:access', // Resource to check access for
34
+ order: 100, // Order of the child
35
+ }
36
+ ],
23
37
  }
24
38
  */
25
39
 
@@ -21,7 +21,8 @@ var SidebarItem = _ref => {
21
21
  setOpen = _ref.setOpen,
22
22
  isSmallResolution = _ref.isSmallResolution,
23
23
  _ref$beacon = _ref.beacon,
24
- beacon = _ref$beacon === void 0 ? undefined : _ref$beacon;
24
+ beacon = _ref$beacon === void 0 ? undefined : _ref$beacon,
25
+ openFor = _ref.openFor;
25
26
  var location = (0, _reactRouter.useLocation)();
26
27
  var _useTranslation = (0, _reactI18next.useTranslation)(),
27
28
  t = _useTranslation.t;
@@ -38,10 +39,10 @@ var SidebarItem = _ref => {
38
39
  var itemBeacon = beacon === null || beacon === void 0 ? void 0 : beacon["beacon.".concat(lowercasedItemName)];
39
40
  (0, _react.useEffect)(() => {
40
41
  // TODO: refactor the handling of active and open states, since it does not behave as expected in some cases
41
- if (isOpen && !isActive) {
42
+ if (isOpen && !isActive && !matchesOpenFor(location.pathname, openFor)) {
42
43
  setOpen(false);
43
44
  }
44
- setActive(item.url && (location.pathname === item.url || location.pathname.startsWith(item.url + '/')) ? true : false);
45
+ setActive(Boolean(item.url && (location.pathname === item.url || location.pathname.startsWith(item.url + '/'))) || matchesOpenFor(location.pathname, item.openFor));
45
46
  }, [location]);
46
47
  return /*#__PURE__*/_react.default.createElement("div", {
47
48
  className: "nav-item"
@@ -103,6 +104,10 @@ var SidebarCollapsibleItem = _ref2 => {
103
104
  // Should collapsed item uncollapse
104
105
  (0, _react.useEffect)(() => {
105
106
  if (item.children && !isOpen) {
107
+ if (matchesOpenFor(location.pathname, item.openFor)) {
108
+ setOpen(true);
109
+ return;
110
+ }
106
111
  for (var child of item.children) {
107
112
  if (location.pathname.includes(child.url)) {
108
113
  setOpen(true);
@@ -164,6 +169,7 @@ var SidebarCollapsibleItem = _ref2 => {
164
169
  isOpen: isOpen,
165
170
  setOpen: setOpen,
166
171
  isSmallResolution: isSmallResolution,
172
+ openFor: item.openFor,
167
173
  beacon: itemsBeacon["beacon.".concat(child === null || child === void 0 || (_child$name = child.name) === null || _child$name === void 0 ? void 0 : _child$name.toLowerCase())] && childBeacon(itemsBeacon, "beacon.".concat(child.name.toLowerCase()))
168
174
  });
169
175
  })));
@@ -186,4 +192,16 @@ var childBeacon = (data, key) => {
186
192
  return data[key] ? {
187
193
  [key]: data[key]
188
194
  } : {};
195
+ };
196
+
197
+ // Optional item.openFor patterns, e.g. ['/route/*'] - keeps parent open on matching routes
198
+ var matchesOpenFor = (pathname, patterns) => {
199
+ var _patterns$some;
200
+ return (_patterns$some = patterns === null || patterns === void 0 ? void 0 : patterns.some(p => {
201
+ if (p.endsWith('*')) {
202
+ var prefix = p.slice(0, -1);
203
+ return pathname.startsWith(prefix) || pathname === prefix.slice(0, -1);
204
+ }
205
+ return pathname === p || pathname.startsWith(p + '/');
206
+ })) !== null && _patterns$some !== void 0 ? _patterns$some : false;
189
207
  };
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+
3
+ var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
4
+ Object.defineProperty(exports, "__esModule", {
5
+ value: true
6
+ });
7
+ exports.LoginLoopCard = LoginLoopCard;
8
+ var _react = _interopRequireDefault(require("react"));
9
+ var _reactI18next = require("react-i18next");
10
+ var _reactstrap = require("reactstrap");
11
+ require("./LoginLoopCard.scss");
12
+ function LoginLoopCard(props) {
13
+ var _props$app;
14
+ var _useTranslation = (0, _reactI18next.useTranslation)(),
15
+ t = _useTranslation.t;
16
+
17
+ // Validate if AuthModule is present
18
+ var hasAuthModule = (_props$app = props.app) === null || _props$app === void 0 || (_props$app = _props$app.Modules) === null || _props$app === void 0 ? void 0 : _props$app.some(m => m.Name === 'AuthModule');
19
+ if (!hasAuthModule) {
20
+ return null;
21
+ }
22
+
23
+ // Read the number of login attempts from sessionStorage and render the card if the number is greater than 10
24
+ var attempts = parseInt(sessionStorage.getItem('SeaCatLoginAttempts') || '0', 10);
25
+ if (!Number.isFinite(attempts) || attempts <= 10) {
26
+ return null;
27
+ }
28
+ return /*#__PURE__*/_react.default.createElement("div", {
29
+ className: "auth-login-loop-wrapper"
30
+ }, /*#__PURE__*/_react.default.createElement(_reactstrap.Card, null, /*#__PURE__*/_react.default.createElement(_reactstrap.CardHeader, {
31
+ className: "card-header-flex"
32
+ }, /*#__PURE__*/_react.default.createElement("div", {
33
+ className: "flex-fill text-center"
34
+ }, /*#__PURE__*/_react.default.createElement("h2", {
35
+ className: "text-primary"
36
+ }, t('ASABAuthModule|Authentication error')))), /*#__PURE__*/_react.default.createElement(_reactstrap.CardBody, null, t('ASABAuthModule|Too many login redirects occurred. Please contact application administrator.'))));
37
+ }
@@ -0,0 +1,6 @@
1
+ .auth-login-loop-wrapper {
2
+ position: absolute;
3
+ z-index: 99999;
4
+ left: 50%; /*Horizontally center*/
5
+ transform: translateX(-50%); /*Horizontally center*/
6
+ }
@@ -46,6 +46,12 @@ class AuthModule extends _asab_webui_components.Module {
46
46
 
47
47
  this.SessionExpiration = null; // Session expiration as defined in user info
48
48
  this.sessionValidationInterval = null; // Initialize session validation interval
49
+ this._sessionExpired = false; // Guard which ensures _triggerSessionExpired() runs at most once
50
+ this._unsubscribeLifecycle = null; // Unsubscribe function for the Application.lifecycle! subscription (set by _markAuthPageActive)
51
+
52
+ // Login-loop protection counts consecutive login redirects and resets on auth success
53
+ var _n = parseInt(sessionStorage.getItem('SeaCatLoginAttempts') || '0', 10);
54
+ this._loginAttempts = Number.isFinite(_n) ? _n : 0;
49
55
 
50
56
  // Access control screen
51
57
  app.Router.addRoute({
@@ -86,6 +92,21 @@ class AuthModule extends _asab_webui_components.Module {
86
92
  */
87
93
  return;
88
94
  }
95
+
96
+ /*
97
+ Duplicated tabs clone sessionStorage and SeaCatAuthTabActive is set
98
+ while the tab is live and cleared on pagehide (refresh/close).
99
+ If the flag (SeaCatAuthTabActive) is still present at startup,
100
+ then its evaluated as a clone and it drops tokens and re-authenticate.
101
+ Skipping the evaluation during the OAuth `code` redirect
102
+ */
103
+ if (authorization_code === null && _this.OAuthTokens != null && sessionStorage.getItem('SeaCatAuthTabActive')) {
104
+ _this.OAuthTokens = null;
105
+ sessionStorage.removeItem('SeaCatOAuth2Tokens');
106
+ sessionStorage.removeItem('SeaCatAuthTabActive');
107
+ yield _this._attemptLogin(_this.RedirectURL, false);
108
+ return;
109
+ }
89
110
  if (authorization_code !== null) {
90
111
  yield _this._exchangeCodeForTokens(authorization_code);
91
112
  // Remove 'code' from a query string
@@ -125,13 +146,19 @@ class AuthModule extends _asab_webui_components.Module {
125
146
  if (!result) {
126
147
  // User info not found - go to login
127
148
  sessionStorage.removeItem('SeaCatOAuth2Tokens');
128
- var force_login_prompt = true;
129
- yield _this.Api.login(_this.RedirectURL, force_login_prompt);
149
+ sessionStorage.removeItem('SeaCatAuthTabActive');
150
+ var force_login_prompt = false;
151
+ yield _this._attemptLogin(_this.RedirectURL, force_login_prompt);
130
152
  return;
131
153
  }
132
154
 
155
+ // Mark current tab as holding an active auth session
156
+ _this._markAuthPageActive();
157
+
133
158
  // Add interceptor with Bearer token in the Header into axios calls
134
159
  _this.App.addAxiosInterceptor(_this.authInterceptor());
160
+ // Add response error interceptor to handle 401 Unauthorized globally
161
+ _this.App.addAxiosResponseErrorInterceptor(_this.unauthorizedInterceptor());
135
162
  // Add webSocket interceptor with Bearer token into websocket calls
136
163
  _this.App.addWebSocketInterceptor(_this.webSocketAuthInterceptor());
137
164
 
@@ -142,7 +169,7 @@ class AuthModule extends _asab_webui_components.Module {
142
169
  if (!tenantAuthorized) {
143
170
  // If tenant not authorized, redirect to Access denied card
144
171
  var _force_login_prompt = false;
145
- yield _this.Api.login(_this.RedirectURL, _force_login_prompt);
172
+ yield _this._attemptLogin(_this.RedirectURL, _force_login_prompt);
146
173
  return;
147
174
  }
148
175
  }
@@ -160,7 +187,7 @@ class AuthModule extends _asab_webui_components.Module {
160
187
  */
161
188
  if (resources == undefined) {
162
189
  var _force_login_prompt2 = false;
163
- yield _this.Api.login(_this.RedirectURL, _force_login_prompt2);
190
+ yield _this._attemptLogin(_this.RedirectURL, _force_login_prompt2);
164
191
  return;
165
192
  }
166
193
  (_this$App = _this.App) === null || _this$App === void 0 || (_this$App = _this$App.AppStore) === null || _this$App === void 0 || (_this$App$dispatch = _this$App.dispatch) === null || _this$App$dispatch === void 0 || _this$App$dispatch.call(_this$App, {
@@ -176,12 +203,15 @@ class AuthModule extends _asab_webui_components.Module {
176
203
  }
177
204
  }
178
205
  if (_this.UserInfo == null && _this.MustAuthenticate) {
179
- // TODO: force_login_prompt = true to break authentication failure loop
180
206
  var _force_login_prompt3 = false;
181
- yield _this.Api.login(_this.RedirectURL, _force_login_prompt3);
207
+ yield _this._attemptLogin(_this.RedirectURL, _force_login_prompt3);
182
208
  return;
183
209
  }
184
210
  }
211
+
212
+ // Authorization completed successfully so reset the login-loop counter
213
+ _this._loginAttempts = 0;
214
+ sessionStorage.removeItem('SeaCatLoginAttempts');
185
215
  _this.App.removeSplashScreenRequestor(_this);
186
216
  })();
187
217
  }
@@ -192,12 +222,56 @@ class AuthModule extends _asab_webui_components.Module {
192
222
  };
193
223
  return interceptor;
194
224
  }
225
+
226
+ // Handle 401 Unauthorized responses from the server
227
+ unauthorizedInterceptor() {
228
+ var _this2 = this;
229
+ var handlingPromise = null; // Shared promise so concurrent 401s await the same refresh/expire flow
230
+ return /*#__PURE__*/function () {
231
+ var _ref = (0, _asyncToGenerator2.default)(function* (error) {
232
+ var _error$response, _error$config, _error$config2;
233
+ if ((error === null || error === void 0 || (_error$response = error.response) === null || _error$response === void 0 ? void 0 : _error$response.status) !== 401) return;
234
+
235
+ // If session expired, do not continue
236
+ if (_this2._sessionExpired) return;
237
+
238
+ // Ignore 401s from auth endpoints themselves to avoid loops
239
+ var requestBaseURL = error === null || error === void 0 || (_error$config = error.config) === null || _error$config === void 0 ? void 0 : _error$config.baseURL;
240
+ var requestPath = error === null || error === void 0 || (_error$config2 = error.config) === null || _error$config2 === void 0 ? void 0 : _error$config2.url;
241
+ var oidcURL = _this2.App.getServiceURL('openidconnect');
242
+ var seacatAuthURL = _this2.App.getServiceURL('seacat-auth');
243
+ // Ignore 401 requests from the oidc service (token/userinfo endpoints) to avoid refresh loops
244
+ // For seacat-auth, only ignore the internal /openidconnect/* sub-path (used by the internal userinfo call)
245
+ if (requestBaseURL && (requestBaseURL === oidcURL || requestBaseURL === seacatAuthURL && (requestPath === '/openidconnect' || requestPath !== null && requestPath !== void 0 && requestPath.startsWith('/openidconnect/')))) {
246
+ return;
247
+ }
248
+ if (!handlingPromise) {
249
+ handlingPromise = (0, _asyncToGenerator2.default)(function* () {
250
+ try {
251
+ yield _this2._refreshTokens();
252
+ var isUserInfoUpdated = yield _this2.updateUserInfo();
253
+ if (!isUserInfoUpdated) {
254
+ _this2._triggerSessionExpired();
255
+ }
256
+ } finally {
257
+ handlingPromise = null;
258
+ }
259
+ })();
260
+ }
261
+ // Await so sessionExpired is set before callers reach addAlertFromException
262
+ yield handlingPromise;
263
+ });
264
+ return function (_x) {
265
+ return _ref.apply(this, arguments);
266
+ };
267
+ }();
268
+ }
195
269
  webSocketAuthInterceptor() {
196
270
  // Return a function to ensure that the token is always current and not static
197
271
  return () => "access_token_".concat(this.OAuthTokens['access_token']);
198
272
  }
199
273
  simulateUserinfo(mock_userinfo) {
200
- var _this2 = this;
274
+ var _this3 = this;
201
275
  return (0, _asyncToGenerator2.default)(function* () {
202
276
  /*
203
277
  This method takes parameters from wepack.dev.js settings
@@ -217,7 +291,7 @@ class AuthModule extends _asab_webui_components.Module {
217
291
  ...
218
292
  ],
219
293
  */
220
- _this2.App.addAlert("warning", "ASABAuthModule|You are using MOCK_USERINFO!", 1, true);
294
+ _this3.App.addAlert("warning", "ASABAuthModule|You are using MOCK_USERINFO!", 1, true);
221
295
  var mockParams = mock_userinfo;
222
296
  if (mockParams.resources) {
223
297
  mockParams["resources"] = Object.values(mockParams.resources);
@@ -228,13 +302,13 @@ class AuthModule extends _asab_webui_components.Module {
228
302
  if (mockParams.tenants) {
229
303
  mockParams["tenants"] = Object.values(mockParams.tenants);
230
304
  }
231
- if (_this2.App.AppStore) {
232
- var _this2$App$AppStore$d, _this2$App$AppStore, _this2$App$AppStore$d2, _this2$App$AppStore2;
233
- (_this2$App$AppStore$d = (_this2$App$AppStore = _this2.App.AppStore).dispatch) === null || _this2$App$AppStore$d === void 0 || _this2$App$AppStore$d.call(_this2$App$AppStore, {
305
+ if (_this3.App.AppStore) {
306
+ var _this3$App$AppStore$d, _this3$App$AppStore, _this3$App$AppStore$d2, _this3$App$AppStore2;
307
+ (_this3$App$AppStore$d = (_this3$App$AppStore = _this3.App.AppStore).dispatch) === null || _this3$App$AppStore$d === void 0 || _this3$App$AppStore$d.call(_this3$App$AppStore, {
234
308
  type: _actions.types.AUTH_USERINFO,
235
309
  payload: mockParams
236
310
  });
237
- (_this2$App$AppStore$d2 = (_this2$App$AppStore2 = _this2.App.AppStore).dispatch) === null || _this2$App$AppStore$d2 === void 0 || _this2$App$AppStore$d2.call(_this2$App$AppStore2, {
311
+ (_this3$App$AppStore$d2 = (_this3$App$AppStore2 = _this3.App.AppStore).dispatch) === null || _this3$App$AppStore$d2 === void 0 || _this3$App$AppStore$d2.call(_this3$App$AppStore2, {
238
312
  type: _actions.types.AUTH_RESOURCES,
239
313
  resources: mockParams["resources"]
240
314
  });
@@ -242,8 +316,8 @@ class AuthModule extends _asab_webui_components.Module {
242
316
 
243
317
  /** Check for TenantService and pass tenants list obtained from userinfo */
244
318
  var availableTenants = mockParams.tenants;
245
- if (_this2.App.Services.TenantService) {
246
- yield _this2.App.Services.TenantService.setTenants(availableTenants, _this2._getAuthorizedTenant(mockParams));
319
+ if (_this3.App.Services.TenantService) {
320
+ yield _this3.App.Services.TenantService.setTenants(availableTenants, _this3._getAuthorizedTenant(mockParams));
247
321
  }
248
322
  })();
249
323
  }
@@ -251,7 +325,11 @@ class AuthModule extends _asab_webui_components.Module {
251
325
  this.App.addSplashScreenRequestor(this);
252
326
  this._stopSessionExpirationValidation(); // Stop session validation and clear the timeout
253
327
 
328
+ // Clear login-loop counter so a fresh login after logout starts from 0
329
+ this._loginAttempts = 0;
330
+ sessionStorage.removeItem('SeaCatLoginAttempts');
254
331
  sessionStorage.removeItem('SeaCatOAuth2Tokens');
332
+ sessionStorage.removeItem('SeaCatAuthTabActive');
255
333
  var promise = this.Api.logout(this.OAuthTokens['access_token']);
256
334
  if (promise == null) {
257
335
  window.location.reload();
@@ -262,9 +340,9 @@ class AuthModule extends _asab_webui_components.Module {
262
340
  window.location.reload();
263
341
  });
264
342
  }
265
- validateNavigation(_ref) {
343
+ validateNavigation(_ref3) {
266
344
  var _state$navigation, _this$App$AppStore$di, _this$App$AppStore;
267
- var resources = _ref.resources;
345
+ var resources = _ref3.resources;
268
346
  var state = this.App.AppStore.getState();
269
347
  var navItems = (_state$navigation = state.navigation) === null || _state$navigation === void 0 ? void 0 : _state$navigation.navItems;
270
348
  var authorizedNavItems = [];
@@ -345,47 +423,47 @@ class AuthModule extends _asab_webui_components.Module {
345
423
  return valid;
346
424
  }
347
425
  updateUserInfo() {
348
- var _this3 = this;
426
+ var _this4 = this;
349
427
  return (0, _asyncToGenerator2.default)(function* () {
350
- var _this3$UserInfo;
351
- var internal = _this3.OAuthTokens.internal || false;
428
+ var _this4$UserInfo;
429
+ var internal = _this4.OAuthTokens.internal || false;
352
430
  if (!internal) {
353
431
  var _response$data;
354
432
  var response;
355
433
  try {
356
- response = yield _this3.Api.userinfo(_this3.OAuthTokens.access_token);
434
+ response = yield _this4.Api.userinfo(_this4.OAuthTokens.access_token);
357
435
  } catch (err) {
358
436
  console.error("Failed to update user info", err);
359
- _this3.UserInfo = null;
360
- if (_this3.App.AppStore) {
361
- var _this3$App$AppStore$d, _this3$App$AppStore;
362
- (_this3$App$AppStore$d = (_this3$App$AppStore = _this3.App.AppStore).dispatch) === null || _this3$App$AppStore$d === void 0 || _this3$App$AppStore$d.call(_this3$App$AppStore, {
437
+ _this4.UserInfo = null;
438
+ if (_this4.App.AppStore) {
439
+ var _this4$App$AppStore$d, _this4$App$AppStore;
440
+ (_this4$App$AppStore$d = (_this4$App$AppStore = _this4.App.AppStore).dispatch) === null || _this4$App$AppStore$d === void 0 || _this4$App$AppStore$d.call(_this4$App$AppStore, {
363
441
  type: _actions.types.AUTH_USERINFO,
364
- payload: _this3.UserInfo
442
+ payload: _this4.UserInfo
365
443
  });
366
444
  }
367
445
  return false;
368
446
  }
369
- _this3.UserInfo = response.data;
370
- _this3.SessionExpiration = (_response$data = response.data) === null || _response$data === void 0 ? void 0 : _response$data.exp;
447
+ _this4.UserInfo = response.data;
448
+ _this4.SessionExpiration = (_response$data = response.data) === null || _response$data === void 0 ? void 0 : _response$data.exp;
371
449
  } else {
372
450
  var _response$data2;
373
451
  var _response;
374
452
  try {
375
- _response = yield _this3.Api.userinfo(_this3.OAuthTokens.access_token, internal);
453
+ _response = yield _this4.Api.userinfo(_this4.OAuthTokens.access_token, internal);
376
454
  } catch (err) {
377
455
  console.error("Failed to update user info", err);
378
- _this3.UserInfo = null;
379
- if (_this3.App.AppStore) {
380
- var _this3$App$AppStore$d2, _this3$App$AppStore2;
381
- (_this3$App$AppStore$d2 = (_this3$App$AppStore2 = _this3.App.AppStore).dispatch) === null || _this3$App$AppStore$d2 === void 0 || _this3$App$AppStore$d2.call(_this3$App$AppStore2, {
456
+ _this4.UserInfo = null;
457
+ if (_this4.App.AppStore) {
458
+ var _this4$App$AppStore$d2, _this4$App$AppStore2;
459
+ (_this4$App$AppStore$d2 = (_this4$App$AppStore2 = _this4.App.AppStore).dispatch) === null || _this4$App$AppStore$d2 === void 0 || _this4$App$AppStore$d2.call(_this4$App$AppStore2, {
382
460
  type: _actions.types.AUTH_USERINFO,
383
- payload: _this3.UserInfo
461
+ payload: _this4.UserInfo
384
462
  });
385
463
  }
386
464
  return false;
387
465
  }
388
- _this3.UserInfo = _response.data;
466
+ _this4.UserInfo = _response.data;
389
467
 
390
468
  // If the resource is `{'*': [....]}` then it is a global resources token (i.e. for API keys)
391
469
  var resources = _response.data.resources;
@@ -394,43 +472,43 @@ class AuthModule extends _asab_webui_components.Module {
394
472
  var tenant = (0, _extractTenantFromUrl.extractTenantFromUrl)();
395
473
  if (tenant) {
396
474
  // Monkey patch the userinfo to add the tenant and resources
397
- _this3.UserInfo['tenants'] = [tenant];
398
- _this3.UserInfo['resources'][tenant] = resources['*'];
475
+ _this4.UserInfo['tenants'] = [tenant];
476
+ _this4.UserInfo['resources'][tenant] = resources['*'];
399
477
  } else {
400
- var _this3$App, _this3$App$dispatch;
478
+ var _this4$App, _this4$App$dispatch;
401
479
  console.error("Tenant not found in URL - if the global resources token is used, the tenant must be specified in the URL");
402
- _this3.UserInfo = null;
403
- _this3.SessionExpiration = null;
404
- (_this3$App = _this3.App) === null || _this3$App === void 0 || (_this3$App = _this3$App.AppStore) === null || _this3$App === void 0 || (_this3$App$dispatch = _this3$App.dispatch) === null || _this3$App$dispatch === void 0 || _this3$App$dispatch.call(_this3$App, {
480
+ _this4.UserInfo = null;
481
+ _this4.SessionExpiration = null;
482
+ (_this4$App = _this4.App) === null || _this4$App === void 0 || (_this4$App = _this4$App.AppStore) === null || _this4$App === void 0 || (_this4$App$dispatch = _this4$App.dispatch) === null || _this4$App$dispatch === void 0 || _this4$App$dispatch.call(_this4$App, {
405
483
  type: _actions.types.AUTH_USERINFO,
406
484
  payload: null
407
485
  });
408
486
  return false;
409
487
  }
410
488
  }
411
- _this3.SessionExpiration = (_response$data2 = _response.data) === null || _response$data2 === void 0 ? void 0 : _response$data2.exp;
489
+ _this4.SessionExpiration = (_response$data2 = _response.data) === null || _response$data2 === void 0 ? void 0 : _response$data2.exp;
412
490
  }
413
- if (_this3.App.AppStore) {
414
- var _this3$App$AppStore$d3, _this3$App$AppStore3;
415
- (_this3$App$AppStore$d3 = (_this3$App$AppStore3 = _this3.App.AppStore).dispatch) === null || _this3$App$AppStore$d3 === void 0 || _this3$App$AppStore$d3.call(_this3$App$AppStore3, {
491
+ if (_this4.App.AppStore) {
492
+ var _this4$App$AppStore$d3, _this4$App$AppStore3;
493
+ (_this4$App$AppStore$d3 = (_this4$App$AppStore3 = _this4.App.AppStore).dispatch) === null || _this4$App$AppStore$d3 === void 0 || _this4$App$AppStore$d3.call(_this4$App$AppStore3, {
416
494
  type: _actions.types.AUTH_USERINFO,
417
- payload: _this3.UserInfo
495
+ payload: _this4.UserInfo
418
496
  });
419
497
  }
420
498
 
421
499
  // Check for TenantService and pass tenants list obtained from userinfo resources
422
- var availableTenants = _this3.UserInfo.tenants;
500
+ var availableTenants = _this4.UserInfo.tenants;
423
501
  if (availableTenants == null || availableTenants.length === 0) {
424
- var _this3$UserInfo$resou;
502
+ var _this4$UserInfo$resou;
425
503
  // Fallback to tenant from resources if tenants list is not available
426
- availableTenants = Object.keys((_this3$UserInfo$resou = _this3.UserInfo.resources) !== null && _this3$UserInfo$resou !== void 0 ? _this3$UserInfo$resou : {}).filter(tenant => tenant !== '*');
504
+ availableTenants = Object.keys((_this4$UserInfo$resou = _this4.UserInfo.resources) !== null && _this4$UserInfo$resou !== void 0 ? _this4$UserInfo$resou : {}).filter(tenant => tenant !== '*');
427
505
  }
428
- if (((_this3$UserInfo = _this3.UserInfo) === null || _this3$UserInfo === void 0 ? void 0 : _this3$UserInfo.tenants) == null) {
506
+ if (((_this4$UserInfo = _this4.UserInfo) === null || _this4$UserInfo === void 0 ? void 0 : _this4$UserInfo.tenants) == null) {
429
507
  // This is a monkey patch to add the tenants list to the userinfo if missing
430
- _this3.UserInfo['tenants'] = availableTenants;
508
+ _this4.UserInfo['tenants'] = availableTenants;
431
509
  }
432
- if (_this3.App.Services.TenantService) {
433
- yield _this3.App.Services.TenantService.setTenants(availableTenants, _this3._getAuthorizedTenant(_this3.UserInfo) // Get the authorized tenant
510
+ if (_this4.App.Services.TenantService) {
511
+ yield _this4.App.Services.TenantService.setTenants(availableTenants, _this4._getAuthorizedTenant(_this4.UserInfo) // Get the authorized tenant
434
512
  );
435
513
  }
436
514
  return true;
@@ -439,12 +517,13 @@ class AuthModule extends _asab_webui_components.Module {
439
517
 
440
518
  // Method for obtaining and storing the OAuth tokens based on authorization code
441
519
  _exchangeCodeForTokens(authorization_code) {
442
- var _this4 = this;
520
+ var _this5 = this;
443
521
  return (0, _asyncToGenerator2.default)(function* () {
444
522
  try {
445
- var response = yield _this4.Api.token_authorization_code(authorization_code, _this4.RedirectURL);
446
- _this4.OAuthTokens = response.data;
523
+ var response = yield _this5.Api.token_authorization_code(authorization_code, _this5.RedirectURL);
524
+ _this5.OAuthTokens = response.data;
447
525
  sessionStorage.setItem('SeaCatOAuth2Tokens', JSON.stringify(response.data));
526
+ _this5._markAuthPageActive();
448
527
  return true;
449
528
  } catch (err) {
450
529
  console.error("Failed to update token", err);
@@ -453,18 +532,61 @@ class AuthModule extends _asab_webui_components.Module {
453
532
  })();
454
533
  }
455
534
 
535
+ /*
536
+ Mark current tab as holding an active auth session
537
+ Cleared on pagehide (refresh/close) so the next load keeps tokens
538
+ A duplicated tab inherits the uncleared flag >> it is detected as a clone
539
+ */
540
+ _markAuthPageActive() {
541
+ sessionStorage.setItem('SeaCatAuthTabActive', '1'); // 1 stands for true (active)
542
+ // Subscribe just once, it is not intentional to trigger pagehide twice
543
+ if (this._unsubscribeLifecycle) return;
544
+ // Subscribe to the pagehide event
545
+ this._unsubscribeLifecycle = this.App.PubSub.subscribe('Application.lifecycle!', _ref4 => {
546
+ var type = _ref4.type,
547
+ persisted = _ref4.persisted;
548
+ if (type === 'pagehide' && !persisted) {
549
+ sessionStorage.removeItem('SeaCatAuthTabActive');
550
+ }
551
+ });
552
+ }
553
+
554
+ /*
555
+ Login-loop protection wrapper around Api.login()
556
+ Each call increments SeaCatLoginAttempts in sessionStorage. After more than
557
+ MAX_LOGIN_ATTEMPTS consecutive redirects without a successful auth, further
558
+ redirects are suppressed.
559
+ The counter is cleared when initialize() completes successfully.
560
+ */
561
+ _attemptLogin(redirectURL) {
562
+ var _arguments = arguments,
563
+ _this6 = this;
564
+ return (0, _asyncToGenerator2.default)(function* () {
565
+ var force_login_prompt = _arguments.length > 1 && _arguments[1] !== undefined ? _arguments[1] : false;
566
+ var MAX_LOGIN_ATTEMPTS = 10;
567
+ _this6._loginAttempts += 1;
568
+ sessionStorage.setItem('SeaCatLoginAttempts', String(_this6._loginAttempts));
569
+ if (_this6._loginAttempts > MAX_LOGIN_ATTEMPTS) {
570
+ // The info card for a user who ends up in a login redirect loop is shown by LoginLoopCard via sessionStorage SeaCatLoginAttempts
571
+ console.error("AuthModule: Login redirect loop detected! ".concat(_this6._loginAttempts, " consecutive login redirects occurred! Please validate the authentication configuration."));
572
+ return;
573
+ }
574
+ yield _this6.Api.login(redirectURL, force_login_prompt);
575
+ })();
576
+ }
577
+
456
578
  // Method for refreshing OAuth tokens
457
579
  _refreshTokens() {
458
- var _this5 = this;
580
+ var _this7 = this;
459
581
  return (0, _asyncToGenerator2.default)(function* () {
460
- var _this5$OAuthTokens;
582
+ var _this7$OAuthTokens;
461
583
  // If no refresh_token found, return false
462
- if (!((_this5$OAuthTokens = _this5.OAuthTokens) !== null && _this5$OAuthTokens !== void 0 && _this5$OAuthTokens.refresh_token)) {
584
+ if (!((_this7$OAuthTokens = _this7.OAuthTokens) !== null && _this7$OAuthTokens !== void 0 && _this7$OAuthTokens.refresh_token)) {
463
585
  return false;
464
586
  }
465
587
  try {
466
- var response = yield _this5.Api.token_refresh(_this5.OAuthTokens.refresh_token);
467
- _this5.OAuthTokens = response.data;
588
+ var response = yield _this7.Api.token_refresh(_this7.OAuthTokens.refresh_token);
589
+ _this7.OAuthTokens = response.data;
468
590
  sessionStorage.setItem('SeaCatOAuth2Tokens', JSON.stringify(response.data));
469
591
  return true;
470
592
  } catch (err) {
@@ -489,11 +611,47 @@ class AuthModule extends _asab_webui_components.Module {
489
611
  }
490
612
  }
491
613
 
614
+ // Trigger session expiration UI: show alert, disable UI, stop validation loop
615
+ _triggerSessionExpired() {
616
+ if (this._sessionExpired) return;
617
+ this._sessionExpired = true;
618
+ clearTimeout(this.sessionValidationInterval);
619
+ this.sessionValidationInterval = null;
620
+
621
+ // Remove the SeaCatAuthTabActive flag and unsubscribe from the pagehide subscription
622
+ sessionStorage.removeItem('SeaCatAuthTabActive');
623
+ if (this._unsubscribeLifecycle) {
624
+ this._unsubscribeLifecycle();
625
+ this._unsubscribeLifecycle = null;
626
+ }
627
+ this.App.addAlert("info", "ASABAuthModule|Your session has expired.", 3600 * 1000, true, alert => /*#__PURE__*/_react.default.createElement(_SessionExpirationAlert.SessionExpirationAlert, {
628
+ alert: alert
629
+ }));
630
+ if (this.App.AppStore) {
631
+ var _this$App$AppStore$di2, _this$App$AppStore2;
632
+ (_this$App$AppStore$di2 = (_this$App$AppStore2 = this.App.AppStore).dispatch) === null || _this$App$AppStore$di2 === void 0 || _this$App$AppStore$di2.call(_this$App$AppStore2, {
633
+ type: _actions.types.AUTH_SESSION_EXPIRATION,
634
+ sessionExpired: true
635
+ });
636
+
637
+ // Disable UI elements
638
+ [...document.querySelectorAll('#app-sidebar .nav-link, [class^="btn"]:not(.alert-button), [class*=" btn"]:not(.alert-button), .btn-group a, .page-item, input, select')].forEach(i => {
639
+ i.classList.add("disabled");
640
+ i.setAttribute("disabled", "");
641
+ });
642
+
643
+ // Reload on navigation actions
644
+ window.addEventListener("popstate", () => {
645
+ window.location.reload();
646
+ });
647
+ }
648
+ }
649
+
492
650
  // Loop validating session expiration
493
651
  _startSessionExpirationValidation() {
494
- var _this6 = this;
652
+ var _this8 = this;
495
653
  return (0, _asyncToGenerator2.default)(function* () {
496
- if (!_this6.SessionExpiration) {
654
+ if (!_this8.SessionExpiration) {
497
655
  console.warn("Session expiration is not set.");
498
656
  return;
499
657
  }
@@ -502,7 +660,7 @@ class AuthModule extends _asab_webui_components.Module {
502
660
  var MAX_SESSION_DURATION = Math.pow(2, 31) - 1;
503
661
 
504
662
  // Proactivelly validate session expiration
505
- var lastKnownExpiration = _this6.SessionExpiration; // Last known session expiration
663
+ var lastKnownExpiration = _this8.SessionExpiration; // Last known session expiration
506
664
  var sessionStartTime = Date.now() / 1000; // Session start time
507
665
  var sessionDuration = lastKnownExpiration - sessionStartTime; // Session duration
508
666
  // Prevent glitches on very large time remaining values
@@ -515,9 +673,9 @@ class AuthModule extends _asab_webui_components.Module {
515
673
  var warningDisplayed = false; // Tracks if the "about to expire" warning has been shown
516
674
 
517
675
  var _validateSession = /*#__PURE__*/function () {
518
- var _ref2 = (0, _asyncToGenerator2.default)(function* () {
676
+ var _ref5 = (0, _asyncToGenerator2.default)(function* () {
519
677
  var currentTime = Date.now() / 1000; // Convert milliseconds to seconds
520
- var timeRemaining = _this6.SessionExpiration - currentTime; // Time difference for triggering "about to expire" warning
678
+ var timeRemaining = _this8.SessionExpiration - currentTime; // Time difference for triggering "about to expire" warning
521
679
  // Prevent glitches on very large time remaining values
522
680
  if (timeRemaining > MAX_SESSION_DURATION) {
523
681
  // Set timeout to maximum allowed value
@@ -527,13 +685,13 @@ class AuthModule extends _asab_webui_components.Module {
527
685
  // Validate session on half of the session expiration or if the remaining time is <= 5min
528
686
  if (!refreshSessionDone && (timeRemaining <= 300 || currentTime >= sessionMidpoint)) {
529
687
  refreshSessionDone = true;
530
- var tokensRefreshed = yield _this6._refreshTokens();
688
+ var tokensRefreshed = yield _this8._refreshTokens();
531
689
  // Recalculate if session expiration was extended
532
690
  if (tokensRefreshed) {
533
- yield _this6.updateUserInfo(); // Update userinfo
691
+ yield _this8.updateUserInfo(); // Update userinfo
534
692
  // If current session expiration differs from last known expiration, recalculate session variables
535
- if (_this6.SessionExpiration !== lastKnownExpiration) {
536
- lastKnownExpiration = _this6.SessionExpiration;
693
+ if (_this8.SessionExpiration !== lastKnownExpiration) {
694
+ lastKnownExpiration = _this8.SessionExpiration;
537
695
  sessionStartTime = currentTime;
538
696
  sessionDuration = lastKnownExpiration - sessionStartTime;
539
697
  // Prevent glitches on very large time remaining values
@@ -549,55 +707,32 @@ class AuthModule extends _asab_webui_components.Module {
549
707
 
550
708
  // If remaining time is between 1 and 60s, repetitivelly ask for userInfo and trigger "about to expire" warning
551
709
  if (timeRemaining <= 60 && timeRemaining > 0) {
552
- var _tokensRefreshed = yield _this6._refreshTokens(); // Returns true/false if token is refreshed/not refreshed
553
- yield _this6.updateUserInfo(); // Continue updating user info
710
+ var _tokensRefreshed = yield _this8._refreshTokens(); // Returns true/false if token is refreshed/not refreshed
711
+ yield _this8.updateUserInfo(); // Continue updating user info
554
712
  if (!_tokensRefreshed && !warningDisplayed) {
555
- _this6.App.addAlert("info", "General|Your session will expire soon", 30, true);
713
+ _this8.App.addAlert("info", "General|Your session will expire soon", 30, true);
556
714
  warningDisplayed = true; // Mark the warning as displayed
557
715
  }
558
716
  }
559
717
 
560
718
  // Validation on expired session
561
- if (_this6.SessionExpiration <= currentTime) {
719
+ if (_this8.SessionExpiration <= currentTime) {
562
720
  // Handle refresh token prior updating user info
563
- yield _this6._refreshTokens();
721
+ yield _this8._refreshTokens();
564
722
  // Handle session expiration
565
- var isUserInfoUpdated = yield _this6.updateUserInfo();
723
+ var isUserInfoUpdated = yield _this8.updateUserInfo();
566
724
  if (!isUserInfoUpdated) {
567
725
  // Stop further checks
568
- clearTimeout(_this6.sessionValidationInterval);
569
- _this6.sessionValidationInterval = null;
570
- _this6.App.addAlert("info", "ASABAuthModule|Your session has expired.", 3600 * 1000, true, alert => /*#__PURE__*/_react.default.createElement(_SessionExpirationAlert.SessionExpirationAlert, {
571
- alert: alert
572
- }));
573
- // Disable UI elements
574
- if (_this6.App.AppStore) {
575
- var _this6$App$AppStore$d, _this6$App$AppStore;
576
- (_this6$App$AppStore$d = (_this6$App$AppStore = _this6.App.AppStore).dispatch) === null || _this6$App$AppStore$d === void 0 || _this6$App$AppStore$d.call(_this6$App$AppStore, {
577
- type: _actions.types.AUTH_SESSION_EXPIRATION,
578
- sessionExpired: true
579
- });
580
-
581
- // Disable UI elements
582
- [...document.querySelectorAll('#app-sidebar .nav-link, [class^="btn"]:not(.alert-button), [class*=" btn"]:not(.alert-button), .btn-group a, .page-item, input, select')].forEach(i => {
583
- i.classList.add("disabled");
584
- i.setAttribute("disabled", "");
585
- });
586
-
587
- // Reload on navigation actions
588
- window.addEventListener("popstate", () => {
589
- window.location.reload();
590
- });
591
- }
726
+ _this8._triggerSessionExpired();
592
727
  return;
593
728
  }
594
729
  }
595
730
 
596
731
  // Re-trigger validation after 10 seconds
597
- _this6.sessionValidationInterval = setTimeout(_validateSession, 10000);
732
+ _this8.sessionValidationInterval = setTimeout(_validateSession, 10000);
598
733
  });
599
734
  return function validateSession() {
600
- return _ref2.apply(this, arguments);
735
+ return _ref5.apply(this, arguments);
601
736
  };
602
737
  }();
603
738
 
@@ -609,7 +744,7 @@ class AuthModule extends _asab_webui_components.Module {
609
744
  // Stop looping on session expiration validation
610
745
  _stopSessionExpirationValidation() {
611
746
  if (this.sessionValidationInterval) {
612
- clearInterval(this.sessionValidationInterval);
747
+ clearTimeout(this.sessionValidationInterval);
613
748
  this.sessionValidationInterval = null;
614
749
  }
615
750
  }
@@ -7,15 +7,11 @@ Object.defineProperty(exports, "__esModule", {
7
7
  exports.default = TenantDropdown;
8
8
  var _slicedToArray2 = _interopRequireDefault(require("@babel/runtime/helpers/slicedToArray"));
9
9
  var _react = _interopRequireWildcard(require("react"));
10
- var _reactRouter = require("react-router");
11
10
  var _asab_webui_components = require("asab_webui_components");
12
11
  var _reactI18next = require("react-i18next");
13
- var _seacatAuth = require("asab_webui_components/seacat-auth");
14
12
  var _reactstrap = require("reactstrap");
15
13
  function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function _interopRequireWildcard(e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (var _t in e) "default" !== _t && {}.hasOwnProperty.call(e, _t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, _t)) && (i.get || i.set) ? o(f, _t, i) : f[_t] = e[_t]); return f; })(e, t); }
16
- function TenantDropdown(_ref) {
17
- var _app$Modules;
18
- var app = _ref.app;
14
+ function TenantDropdown() {
19
15
  var _useTranslation = (0, _reactI18next.useTranslation)(),
20
16
  t = _useTranslation.t;
21
17
  var _useState = (0, _react.useState)(false),
@@ -34,9 +30,6 @@ function TenantDropdown(_ref) {
34
30
  var _state$tenant2;
35
31
  return state === null || state === void 0 || (_state$tenant2 = state.tenant) === null || _state$tenant2 === void 0 ? void 0 : _state$tenant2.tenants;
36
32
  });
37
- var canCreateTenant = (app === null || app === void 0 || (_app$Modules = app.Modules) === null || _app$Modules === void 0 ? void 0 : _app$Modules.some(obj => (obj === null || obj === void 0 ? void 0 : obj.Name) === "LmioTrexModule")) && (0, _seacatAuth.isAuthorized)(['lmio:tenant:create'], app);
38
- var tenantsAvailable = tenants && tenants.length > 0;
39
- var showDropdown = tenantsAvailable || canCreateTenant;
40
33
  var hasSearch = (tenants === null || tenants === void 0 ? void 0 : tenants.length) >= 10; // Display search input if there is >= 10 tenants
41
34
  var toggle = () => {
42
35
  setIsOpen(prev => !prev);
@@ -59,12 +52,12 @@ function TenantDropdown(_ref) {
59
52
  className: "bi bi-house-lock pe-2"
60
53
  }), /*#__PURE__*/_react.default.createElement(TenantLabel, {
61
54
  tenant: current
62
- })), isOpen && showDropdown && /*#__PURE__*/_react.default.createElement(_reactstrap.DropdownMenu, {
55
+ })), isOpen && (tenants === null || tenants === void 0 ? void 0 : tenants.length) > 0 && /*#__PURE__*/_react.default.createElement(_reactstrap.DropdownMenu, {
63
56
  className: "shadow overflow-y-auto".concat(hasSearch ? ' pt-0' : ''),
64
57
  style: {
65
58
  maxHeight: '20em'
66
59
  }
67
- }, tenantsAvailable && /*#__PURE__*/_react.default.createElement(_react.default.Fragment, null, /*#__PURE__*/_react.default.createElement(_reactstrap.DropdownItem, {
60
+ }, /*#__PURE__*/_react.default.createElement(_reactstrap.DropdownItem, {
68
61
  header: true
69
62
  }, t('General|Tenants')), hasSearch && /*#__PURE__*/_react.default.createElement(_reactstrap.Input, {
70
63
  value: searchInput,
@@ -78,14 +71,9 @@ function TenantDropdown(_ref) {
78
71
  href: '?tenant=' + tenant + '#/'
79
72
  }, /*#__PURE__*/_react.default.createElement(TenantLabel, {
80
73
  tenant: tenant
81
- })))), canCreateTenant && /*#__PURE__*/_react.default.createElement(_react.default.Fragment, null, tenantsAvailable && /*#__PURE__*/_react.default.createElement(_reactstrap.DropdownItem, {
82
- divider: true
83
- }), /*#__PURE__*/_react.default.createElement(_reactstrap.DropdownItem, {
84
- tag: _reactRouter.Link,
85
- to: "/config/tenant/!create"
86
- }, t('TenantDropdown|Create tenant')))));
74
+ })))));
87
75
  }
88
- function TenantLabel(_ref2) {
89
- var tenant = _ref2.tenant;
76
+ function TenantLabel(_ref) {
77
+ var tenant = _ref.tenant;
90
78
  return /*#__PURE__*/_react.default.createElement("span", null, tenant);
91
79
  }
@@ -0,0 +1,87 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.subscribePageHide = subscribePageHide;
7
+ /*
8
+ Page lifecycle helpers
9
+
10
+ Low-level fan-out wrappers around browser page lifecycle events.
11
+ These are used exclusively by Application._initPageLifecycleBridge to build
12
+ the app-level PubSub topic `Application.lifecycle!`
13
+
14
+ Exported event types bridged:
15
+ 'pagehide' - subscribePageHide
16
+ TODO: implement 'pageshow'
17
+
18
+ subscribePageHide attaches a single window 'pagehide' listener and fans out
19
+ to registered callbacks. Callers receive the native PageTransitionEvent and
20
+ should check event.persisted when they care about bfcache vs real unload.
21
+
22
+ Returns an unsubscribe function. The window listener is removed when the
23
+ last subscriber unsubscribes.
24
+
25
+ An optional AbortSignal can be passed as the second argument to tie the
26
+ subscription lifetime to an AbortController - the callback is removed
27
+ automatically when the signal is aborted, without needing to call the
28
+ returned unsubscribe function explicitly.
29
+
30
+ Usage:
31
+
32
+ PREFERRED - subscribe via the app-level PubSub topic Application.lifecycle!
33
+ Current types: 'pagehide' (more will be added, e.g. 'pageshow')
34
+
35
+ import { usePubSub } from 'asab_webui_components';
36
+
37
+ function MyComponent() {
38
+ const { subscribe } = usePubSub();
39
+ useEffect(() => {
40
+ const unsubscribe = subscribe('Application.lifecycle!', ({ type, persisted }) => {
41
+ if (type === 'pagehide' && !persisted) {
42
+ // real unload
43
+ }
44
+ });
45
+ return unsubscribe; // React cleans up on unmount
46
+ }, []);
47
+ }
48
+ */
49
+
50
+ var pageHideCallbacks = new Set();
51
+ function onPageHide(event) {
52
+ pageHideCallbacks.forEach(callback => {
53
+ try {
54
+ callback(event);
55
+ } catch (err) {
56
+ console.error('pageLifecycle: pagehide callback failed', err);
57
+ }
58
+ });
59
+ }
60
+
61
+ // Subscribe to window 'pagehide' event
62
+ function subscribePageHide(callback, signal) {
63
+ if (typeof callback !== 'function') {
64
+ return () => {};
65
+ }
66
+ if (typeof window === 'undefined') {
67
+ return () => {};
68
+ }
69
+ if (signal !== null && signal !== void 0 && signal.aborted) {
70
+ return () => {};
71
+ }
72
+ var wasEmpty = pageHideCallbacks.size === 0;
73
+ pageHideCallbacks.add(callback);
74
+ if (wasEmpty) {
75
+ window.addEventListener('pagehide', onPageHide);
76
+ }
77
+ var unsubscribe = () => {
78
+ pageHideCallbacks.delete(callback);
79
+ if (pageHideCallbacks.size === 0 && typeof window !== 'undefined') {
80
+ window.removeEventListener('pagehide', onPageHide);
81
+ }
82
+ };
83
+ signal === null || signal === void 0 || signal.addEventListener('abort', unsubscribe, {
84
+ once: true
85
+ });
86
+ return unsubscribe;
87
+ }
@@ -9,8 +9,13 @@ exports.STATUS_ALERTS = void 0;
9
9
  This is a extension to the addAlertFromException function in the Application.js file.
10
10
 
11
11
  The 502, 503 and 504 errors are handled directly in the Application.js file, since it does not render an alert at all.
12
+ The 401 alert is suppressed in Application.js when the session has expired (AuthModule shows SessionExpirationAlert).
12
13
  */
13
14
  var STATUS_ALERTS = exports.STATUS_ALERTS = {
15
+ 401: {
16
+ level: 'warning',
17
+ message: 'General|Unauthorized request. Please contact the administrator.'
18
+ },
14
19
  408: {
15
20
  level: 'warning',
16
21
  message: 'General|The request timed out. Please try again.'
@@ -3,8 +3,13 @@
3
3
  This is a extension to the addAlertFromException function in the Application.js file.
4
4
 
5
5
  The 502, 503 and 504 errors are handled directly in the Application.js file, since it does not render an alert at all.
6
+ The 401 alert is suppressed in Application.js when the session has expired (AuthModule shows SessionExpirationAlert).
6
7
  */
7
8
  export const STATUS_ALERTS = {
9
+ 401: {
10
+ level: 'warning',
11
+ message: 'General|Unauthorized request. Please contact the administrator.',
12
+ },
8
13
  408: {
9
14
  level: 'warning',
10
15
  message: 'General|The request timed out. Please try again.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "asab_webui_shell",
3
- "version": "27.7.5-alpha.0",
3
+ "version": "27.8.1",
4
4
  "license": "BSD-3-Clause",
5
5
  "description": "TeskaLabs ASAB WebUI Shell Application",
6
6
  "contributors": [