@jetbrains/ring-ui 7.0.94 → 7.0.96

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.
@@ -8,6 +8,7 @@ import TokenValidator, { type TokenValidationError, type TokenValidatorConfig }
8
8
  import type AuthDialogService from '../auth-dialog-service/auth-dialog-service';
9
9
  export declare const DEFAULT_EXPIRES_TIMEOUT: number;
10
10
  export declare const DEFAULT_BACKGROUND_TIMEOUT: number;
11
+ export declare const TOKEN_REFRESH_RETRY_DELAYS: number[];
11
12
  export declare const USER_CHANGED_EVENT = "userChange";
12
13
  export declare const DOMAIN_USER_CHANGED_EVENT = "domainUser";
13
14
  export declare const LOGOUT_EVENT = "logout";
@@ -80,6 +81,8 @@ export interface AuthConfig extends TokenValidatorConfig {
80
81
  translations?: AuthTranslations | null | undefined;
81
82
  userParams?: RequestParams | undefined;
82
83
  waitForRedirectTimeout: number;
84
+ tokenRefreshRetryDelays: readonly number[];
85
+ rpInitiatedLogout: boolean;
83
86
  }
84
87
  type AuthPayloadMap = {
85
88
  userChange: [AuthUser | undefined | void, void];
@@ -120,6 +123,7 @@ declare class Auth implements HTTPAuth {
120
123
  static DEFAULT_CONFIG: Omit<AuthConfig, "serverUri">;
121
124
  static API_PATH: string;
122
125
  static API_AUTH_PATH: string;
126
+ static API_LOGOUT_PATH: string;
123
127
  static API_PROFILE_PATH: string;
124
128
  static CLOSE_BACKEND_DOWN_MESSAGE: string;
125
129
  static CLOSE_WINDOW_MESSAGE: string;
@@ -137,6 +141,7 @@ declare class Auth implements HTTPAuth {
137
141
  _tokenValidator: TokenValidator | null;
138
142
  private _postponed;
139
143
  private _backendCheckPromise;
144
+ private _forceTokenUpdatePromise;
140
145
  private _authDialogService;
141
146
  _domainStorage: AuthStorage<UserChange>;
142
147
  user: AuthUser | null;
@@ -166,9 +171,18 @@ declare class Auth implements HTTPAuth {
166
171
  requestToken(): Promise<string | null>;
167
172
  /**
168
173
  * Get new token in the background or redirect to the login page.
169
- * @return {Promise.<string>}
174
+ *
175
+ * Retries background token refresh with delays from {@link AuthConfig.tokenRefreshRetryDelays}
176
+ * with increasing delays before showing the auth dialog.
177
+ * This handles transient failures that commonly occur after network
178
+ * recovery (e.g. waking from sleep, switching networks) where the first
179
+ * iframe-based auth attempt fails but a subsequent one succeeds once
180
+ * the Hub session is re-established.
181
+ *
182
+ * @return {Promise.<string | null>}
170
183
  */
171
184
  forceTokenUpdate(): Promise<string | null>;
185
+ private _doForceTokenUpdate;
172
186
  loadCurrentService(): Promise<void>;
173
187
  getAPIPath(): string;
174
188
  /**
@@ -187,7 +201,10 @@ declare class Auth implements HTTPAuth {
187
201
  private _extractErrorMessage;
188
202
  private _showBackendDownDialog;
189
203
  /**
190
- * Wipe accessToken and redirect to auth page with required authorization
204
+ * Wipe accessToken and redirect to logout endpoint.
205
+ * Uses RP-initiated logout flow (oauth2/logout) when rpInitiatedLogout config is enabled,
206
+ * falls back to oauth2/auth redirect otherwise.
207
+ * See: https://youtrack.jetbrains.com/projects/HUB/articles/HUB-A-43#rp-initiated-logout
191
208
  */
192
209
  logout(extraParams?: Record<string, unknown>): Promise<void>;
193
210
  private _runEmbeddedLogin;
@@ -15,6 +15,7 @@ export const DEFAULT_BACKGROUND_TIMEOUT = 10 * 1000;
15
15
  const DEFAULT_BACKEND_CHECK_TIMEOUT = 10 * 1000;
16
16
  const BACKGROUND_REDIRECT_TIMEOUT = 20 * 1000;
17
17
  const DEFAULT_WAIT_FOR_REDIRECT_TIMEOUT = 5 * 1000;
18
+ export const TOKEN_REFRESH_RETRY_DELAYS = [0, 2000, 5000];
18
19
  export const USER_CHANGED_EVENT = 'userChange';
19
20
  export const DOMAIN_USER_CHANGED_EVENT = 'domainUser';
20
21
  export const LOGOUT_EVENT = 'logout';
@@ -43,12 +44,15 @@ const DEFAULT_CONFIG = {
43
44
  onBackendDown: () => () => { },
44
45
  defaultExpiresIn: DEFAULT_EXPIRES_TIMEOUT,
45
46
  waitForRedirectTimeout: DEFAULT_WAIT_FOR_REDIRECT_TIMEOUT,
47
+ tokenRefreshRetryDelays: TOKEN_REFRESH_RETRY_DELAYS,
48
+ rpInitiatedLogout: true,
46
49
  translations: null,
47
50
  };
48
51
  class Auth {
49
52
  static DEFAULT_CONFIG = DEFAULT_CONFIG;
50
53
  static API_PATH = 'api/rest/';
51
54
  static API_AUTH_PATH = 'oauth2/auth';
55
+ static API_LOGOUT_PATH = 'oauth2/logout';
52
56
  static API_PROFILE_PATH = 'users/me';
53
57
  static CLOSE_BACKEND_DOWN_MESSAGE = 'backend-check-succeeded';
54
58
  static CLOSE_WINDOW_MESSAGE = 'close-login-window';
@@ -66,6 +70,7 @@ class Auth {
66
70
  _tokenValidator = null;
67
71
  _postponed = false;
68
72
  _backendCheckPromise = null;
73
+ _forceTokenUpdatePromise = null;
69
74
  _authDialogService = undefined;
70
75
  _domainStorage;
71
76
  user = null;
@@ -106,6 +111,7 @@ class Auth {
106
111
  this._domainStorage = new AuthStorage({ messagePrefix: 'domain-message-' });
107
112
  this._requestBuilder = new AuthRequestBuilder({
108
113
  authorization: this.config.serverUri + Auth.API_PATH + Auth.API_AUTH_PATH,
114
+ logout: this.config.serverUri + Auth.API_PATH + Auth.API_LOGOUT_PATH,
109
115
  clientId,
110
116
  redirect,
111
117
  redirectUri,
@@ -205,6 +211,8 @@ class Auth {
205
211
  throw error;
206
212
  }
207
213
  if (this._canShowDialogs()) {
214
+ // eslint-disable-next-line no-console
215
+ console.error('RingUI Auth: Init failure', error);
208
216
  this._showAuthDialog({ nonInteractive: true, error });
209
217
  }
210
218
  }
@@ -218,7 +226,7 @@ class Auth {
218
226
  if (this.user && userID === this.user.id) {
219
227
  return;
220
228
  }
221
- this.forceTokenUpdate();
229
+ this.forceTokenUpdate().catch(noop);
222
230
  });
223
231
  let state;
224
232
  try {
@@ -240,7 +248,7 @@ class Auth {
240
248
  if (message) {
241
249
  const { userID, serviceID } = message;
242
250
  if (serviceID !== this.config.clientId && (!userID || this.user?.id !== userID)) {
243
- this.forceTokenUpdate();
251
+ this.forceTokenUpdate().catch(noop);
244
252
  }
245
253
  }
246
254
  // Access token appears to be valid.
@@ -338,9 +346,26 @@ class Auth {
338
346
  }
339
347
  /**
340
348
  * Get new token in the background or redirect to the login page.
341
- * @return {Promise.<string>}
349
+ *
350
+ * Retries background token refresh with delays from {@link AuthConfig.tokenRefreshRetryDelays}
351
+ * with increasing delays before showing the auth dialog.
352
+ * This handles transient failures that commonly occur after network
353
+ * recovery (e.g. waking from sleep, switching networks) where the first
354
+ * iframe-based auth attempt fails but a subsequent one succeeds once
355
+ * the Hub session is re-established.
356
+ *
357
+ * @return {Promise.<string | null>}
342
358
  */
343
- async forceTokenUpdate() {
359
+ forceTokenUpdate() {
360
+ if (this._forceTokenUpdatePromise) {
361
+ return this._forceTokenUpdatePromise;
362
+ }
363
+ this._forceTokenUpdatePromise = this._doForceTokenUpdate().finally(() => {
364
+ this._forceTokenUpdatePromise = null;
365
+ });
366
+ return this._forceTokenUpdatePromise;
367
+ }
368
+ async _doForceTokenUpdate() {
344
369
  try {
345
370
  if (!this._backendCheckPromise) {
346
371
  this._backendCheckPromise = this._checkBackendsStatusesIfEnabled();
@@ -353,44 +378,48 @@ class Auth {
353
378
  finally {
354
379
  this._backendCheckPromise = null;
355
380
  }
356
- try {
357
- return (await this._backgroundFlow?.authorize()) ?? null;
358
- }
359
- catch (error) {
360
- if (!(error instanceof Error)) {
361
- return null;
381
+ let lastError = null;
382
+ for (const delay of this.config.tokenRefreshRetryDelays) {
383
+ if (delay > 0) {
384
+ await new Promise(resolve => setTimeout(resolve, delay));
362
385
  }
363
- if (this._canShowDialogs()) {
364
- return new Promise(resolve => {
365
- const onTryAgain = async () => {
366
- try {
367
- const result = await this._backgroundFlow?.authorize();
368
- resolve(result ?? null);
369
- }
370
- catch (retryError) {
371
- if (retryError instanceof Error) {
372
- this._showAuthDialog({
373
- nonInteractive: true,
374
- error: retryError,
375
- onTryAgain,
376
- });
377
- }
378
- throw retryError;
379
- }
380
- };
381
- this._showAuthDialog({
382
- nonInteractive: true,
383
- error: error,
384
- onTryAgain,
385
- });
386
- });
386
+ try {
387
+ return (await this._backgroundFlow?.authorize()) ?? null;
387
388
  }
388
- const authRequest = await this._requestBuilder?.prepareAuthRequest();
389
- if (authRequest) {
390
- this._redirectCurrentPage(authRequest.url);
389
+ catch (error) {
390
+ lastError = error instanceof Error ? error : new Error(String(error));
391
391
  }
392
- throw new TokenValidator.TokenValidationError(error.message);
393
392
  }
393
+ if (this._canShowDialogs()) {
394
+ return new Promise(resolve => {
395
+ const onTryAgain = async () => {
396
+ try {
397
+ const result = await this._backgroundFlow?.authorize();
398
+ resolve(result ?? null);
399
+ }
400
+ catch (retryError) {
401
+ if (retryError instanceof Error) {
402
+ this._showAuthDialog({
403
+ nonInteractive: true,
404
+ error: retryError,
405
+ onTryAgain,
406
+ });
407
+ }
408
+ throw retryError;
409
+ }
410
+ };
411
+ this._showAuthDialog({
412
+ nonInteractive: true,
413
+ error: lastError,
414
+ onTryAgain,
415
+ });
416
+ });
417
+ }
418
+ const authRequest = await this._requestBuilder?.prepareAuthRequest();
419
+ if (authRequest) {
420
+ this._redirectCurrentPage(authRequest.url);
421
+ }
422
+ throw new TokenValidator.TokenValidationError(lastError?.message ?? 'Failed to refresh token');
394
423
  }
395
424
  async loadCurrentService() {
396
425
  if (this._service.serviceName) {
@@ -468,7 +497,10 @@ class Auth {
468
497
  }
469
498
  _beforeLogout(params) {
470
499
  if (this._canShowDialogs()) {
471
- this._showAuthDialog(params);
500
+ const onTryAgain = async () => {
501
+ await this.forceTokenUpdate();
502
+ };
503
+ this._showAuthDialog({ onTryAgain, ...params });
472
504
  return;
473
505
  }
474
506
  this.logout();
@@ -504,7 +536,7 @@ class Auth {
504
536
  return;
505
537
  }
506
538
  if (this.user?.guest && nonInteractive) {
507
- this.forceTokenUpdate();
539
+ this.forceTokenUpdate().catch(noop);
508
540
  }
509
541
  else {
510
542
  this._initDeferred?.resolve?.();
@@ -633,20 +665,24 @@ class Auth {
633
665
  });
634
666
  }
635
667
  /**
636
- * Wipe accessToken and redirect to auth page with required authorization
668
+ * Wipe accessToken and redirect to logout endpoint.
669
+ * Uses RP-initiated logout flow (oauth2/logout) when rpInitiatedLogout config is enabled,
670
+ * falls back to oauth2/auth redirect otherwise.
671
+ * See: https://youtrack.jetbrains.com/projects/HUB/articles/HUB-A-43#rp-initiated-logout
637
672
  */
638
673
  async logout(extraParams) {
639
- const requestParams = {
640
- request_credentials: 'required',
641
- ...extraParams,
642
- };
643
674
  await this._checkBackendsStatusesIfEnabled();
644
675
  await this.listeners.trigger('logout');
645
676
  this._updateDomainUser(null);
646
677
  await this._storage?.wipeToken();
647
- const authRequest = await this._requestBuilder?.prepareAuthRequest(requestParams);
648
- if (authRequest) {
649
- this._redirectCurrentPage(authRequest.url);
678
+ const request = this.config.rpInitiatedLogout
679
+ ? await this._requestBuilder?.prepareLogoutRequest(extraParams)
680
+ : await this._requestBuilder?.prepareAuthRequest({
681
+ request_credentials: 'required',
682
+ ...extraParams,
683
+ });
684
+ if (request) {
685
+ this._redirectCurrentPage(request.url);
650
686
  }
651
687
  }
652
688
  async _runEmbeddedLogin() {
@@ -681,6 +717,8 @@ class Auth {
681
717
  }
682
718
  }
683
719
  catch (e) {
720
+ // eslint-disable-next-line no-console
721
+ console.error('RingUI Auth: login failure', e);
684
722
  this._beforeLogout();
685
723
  }
686
724
  }
@@ -3,6 +3,7 @@ import type { AuthState } from './storage';
3
3
  import type AuthStorage from './storage';
4
4
  export interface AuthRequestBuilderConfig {
5
5
  authorization: string;
6
+ logout?: string | null | undefined;
6
7
  redirectUri?: string | null | undefined;
7
8
  requestCredentials?: string | null | undefined;
8
9
  clientId?: string | null | undefined;
@@ -38,6 +39,17 @@ export default class AuthRequestBuilder {
38
39
  url: string;
39
40
  stateId: string;
40
41
  }>;
42
+ /**
43
+ * Build a logout URL for RP-initiated logout flow.
44
+ * See: https://youtrack.jetbrains.com/projects/HUB/articles/HUB-A-43#rp-initiated-logout
45
+ *
46
+ * @param {object=} extraParams additional query parameters for logout request
47
+ * @return {Promise.<{url: string, stateId: string}>} logout URL with required parameters
48
+ */
49
+ prepareLogoutRequest(extraParams?: Record<string, unknown> | null | undefined): Promise<{
50
+ url: string;
51
+ stateId: string;
52
+ }>;
41
53
  /**
42
54
  * @param {string} id
43
55
  * @param {StoredState} storedState
@@ -51,6 +51,33 @@ export default class AuthRequestBuilder {
51
51
  stateId,
52
52
  };
53
53
  }
54
+ /**
55
+ * Build a logout URL for RP-initiated logout flow.
56
+ * See: https://youtrack.jetbrains.com/projects/HUB/articles/HUB-A-43#rp-initiated-logout
57
+ *
58
+ * @param {object=} extraParams additional query parameters for logout request
59
+ * @return {Promise.<{url: string, stateId: string}>} logout URL with required parameters
60
+ */
61
+ async prepareLogoutRequest(extraParams) {
62
+ if (!this.config.logout) {
63
+ throw new Error('Logout URL is not configured');
64
+ }
65
+ // eslint-disable-next-line no-underscore-dangle
66
+ const stateId = AuthRequestBuilder._uuid();
67
+ const logoutParams = {
68
+ client_id: this.config.clientId,
69
+ state: stateId,
70
+ ...extraParams,
71
+ };
72
+ await this._saveState(stateId, {
73
+ restoreLocation: window.location.href,
74
+ scopes: [...this.config.scopes],
75
+ });
76
+ return {
77
+ url: encodeURL(this.config.logout, logoutParams),
78
+ stateId,
79
+ };
80
+ }
54
81
  /**
55
82
  * @param {string} id
56
83
  * @param {StoredState} storedState
@@ -109,7 +109,7 @@ export default class List<T = unknown> extends Component<ListProps<T>, ListState
109
109
  };
110
110
  componentDidMount(): void;
111
111
  shouldComponentUpdate(nextProps: ListProps<T>, nextState: ListState<T>): boolean;
112
- componentDidUpdate(prevProps: ListProps<T>): void;
112
+ componentDidUpdate(prevProps: ListProps<T>, prevState: ListState<T>): void;
113
113
  componentWillUnmount(): void;
114
114
  scheduleScrollListener: (cb: () => void) => void;
115
115
  static isItemType: typeof isItemType;
@@ -126,11 +126,24 @@ export default class List extends Component {
126
126
  return (Object.keys(nextProps).some(key => !Object.is(nextProps[key], this.props[key])) ||
127
127
  Object.keys(nextState).some(key => nextState[key] !== this.state[key]));
128
128
  }
129
- componentDidUpdate(prevProps) {
129
+ componentDidUpdate(prevProps, prevState) {
130
130
  if (this.virtualizedList && prevProps.data !== this.props.data) {
131
131
  this.virtualizedList.recomputeRowHeights();
132
132
  }
133
133
  const { activeIndex } = this.state;
134
+ if (!this.virtualizedList &&
135
+ !this.props.disableScrollToActive &&
136
+ this.state.needScrollToActive &&
137
+ activeIndex != null &&
138
+ activeIndex !== prevState.activeIndex) {
139
+ const itemId = this.getId(this.props.data[activeIndex]);
140
+ if (itemId) {
141
+ document.getElementById(itemId)?.scrollIntoView?.({
142
+ block: 'center',
143
+ });
144
+ }
145
+ this.setState({ needScrollToActive: false });
146
+ }
134
147
  const isActiveItemRetainedPosition = activeIndex
135
148
  ? prevProps.data[activeIndex]?.key === this.props.data[activeIndex]?.key
136
149
  : false;
@@ -15,7 +15,7 @@ class Tabs extends PureComponent {
15
15
  const { selected, children } = this.props;
16
16
  const childrenArray = React.Children.toArray(children).filter(Boolean);
17
17
  const selectedIndex = childrenArray.findIndex((tab, i) => getTabId(tab, i) === selected);
18
- const actualSelectedIndex = selectedIndex === -1 ? 0 : selectedIndex;
18
+ const actualSelectedIndex = selectedIndex === -1 ? childrenArray.findIndex(tab => tab.type !== CustomItem) : selectedIndex;
19
19
  const selectedItem = childrenArray[actualSelectedIndex];
20
20
  return { selectedItem, selectedKey: getTabId(selectedItem, actualSelectedIndex) };
21
21
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jetbrains/ring-ui",
3
- "version": "7.0.94",
3
+ "version": "7.0.96",
4
4
  "description": "JetBrains UI library",
5
5
  "author": {
6
6
  "name": "JetBrains"
@@ -100,7 +100,7 @@
100
100
  "@csstools/stylelint-no-at-nest-rule": "^5.0.0",
101
101
  "@eslint/compat": "^2.0.2",
102
102
  "@eslint/eslintrc": "^3.3.3",
103
- "@eslint/js": "^9.39.2",
103
+ "@eslint/js": "^10.0.1",
104
104
  "@figma/code-connect": "^1.3.13",
105
105
  "@jetbrains/eslint-config": "^6.0.5",
106
106
  "@jetbrains/logos": "3.0.0-canary.734b213.0",
@@ -128,17 +128,17 @@
128
128
  "@types/react-dom": "^19.2.3",
129
129
  "@types/webpack-env": "^1.18.8",
130
130
  "@vitejs/plugin-react": "^5.1.4",
131
- "@vitest/eslint-plugin": "^1.6.7",
131
+ "@vitest/eslint-plugin": "^1.6.9",
132
132
  "acorn": "^8.15.0",
133
133
  "babel-plugin-require-context-hook": "^1.0.0",
134
- "caniuse-lite": "^1.0.30001769",
134
+ "caniuse-lite": "^1.0.30001770",
135
135
  "chai-as-promised": "^8.0.2",
136
136
  "chai-dom": "^1.12.1",
137
137
  "cheerio": "^1.2.0",
138
138
  "core-js": "^3.48.0",
139
139
  "cpy-cli": "^7.0.0",
140
140
  "dotenv-cli": "^11.0.0",
141
- "eslint": "^9.39.2",
141
+ "eslint": "^10.0.1",
142
142
  "eslint-config-prettier": "^10.1.8",
143
143
  "eslint-import-resolver-exports": "^1.0.0-beta.5",
144
144
  "eslint-import-resolver-typescript": "^4.4.4",
@@ -149,7 +149,7 @@
149
149
  "eslint-plugin-react": "^7.37.5",
150
150
  "eslint-plugin-react-hooks": "^7.0.1",
151
151
  "eslint-plugin-storybook": "^10.2.8",
152
- "eslint-plugin-unicorn": "^62.0.0",
152
+ "eslint-plugin-unicorn": "^63.0.0",
153
153
  "events": "^3.3.0",
154
154
  "glob": "^13.0.3",
155
155
  "globals": "^17.3.0",
@@ -169,13 +169,13 @@
169
169
  "react": "^19.2.4",
170
170
  "react-dom": "^19.2.4",
171
171
  "regenerator-runtime": "^0.14.1",
172
- "rimraf": "^6.1.2",
172
+ "rimraf": "^6.1.3",
173
173
  "rollup": "^4.57.1",
174
174
  "rollup-plugin-clear": "^2.0.7",
175
175
  "storage-mock": "^2.1.0",
176
- "storybook": "10.2.8",
176
+ "storybook": "10.2.13",
177
177
  "stylelint": "^17.3.0",
178
- "stylelint-config-sass-guidelines": "^12.1.0",
178
+ "stylelint-config-sass-guidelines": "^13.0.0",
179
179
  "svg-inline-loader": "^0.8.2",
180
180
  "teamcity-service-messages": "^0.1.14",
181
181
  "terser-webpack-plugin": "^5.3.16",
@@ -223,7 +223,7 @@
223
223
  "change-case": "^4.1.1",
224
224
  "classnames": "^2.5.1",
225
225
  "combokeys": "^3.0.1",
226
- "css-loader": "^7.1.3",
226
+ "css-loader": "^7.1.4",
227
227
  "csstype": "^3.2.1",
228
228
  "date-fns": "^4.1.0",
229
229
  "dequal": "^2.0.3",
@@ -238,7 +238,7 @@
238
238
  "postcss-calc": "^10.1.1",
239
239
  "postcss-flexbugs-fixes": "^5.0.2",
240
240
  "postcss-font-family-system-ui": "^5.0.0",
241
- "postcss-loader": "^8.2.0",
241
+ "postcss-loader": "^8.2.1",
242
242
  "postcss-modules-values-replace": "^4.2.2",
243
243
  "postcss-preset-env": "^11.1.3",
244
244
  "react-compiler-runtime": "^1.0.0",