diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/AddRoleAndAssignToUser.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/AddRoleAndAssignToUser.spec.ts index bf6b6e042114..fc135bd7e1bc 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/AddRoleAndAssignToUser.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/AddRoleAndAssignToUser.spec.ts @@ -100,6 +100,7 @@ test.describe.serial('Add role and assign it to the user', () => { test('Verify assigned role to new user', async ({ page }) => { await settingClick(page, GlobalSettingOptions.USERS); + await page.waitForSelector('[data-testid="loader"]', { state: 'hidden' }); const searchUser = page.waitForResponse( `/api/v1/search/query?q=*${encodeURIComponent(userDisplayName)}*` ); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/dataInsightApp.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/dataInsightApp.ts index a2be9b2a9106..d77075837471 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/dataInsightApp.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/dataInsightApp.ts @@ -33,17 +33,51 @@ setup( await table.create(apiContext); - apiContext.patch(`/api/v1/tables/${table.entityResponseData?.id ?? ''}`, { + await apiContext.patch( + `/api/v1/tables/${table.entityResponseData?.id ?? ''}`, + { + data: [ + { + op: 'add', + path: '/tags/0', + value: { + name: 'Tier2', + tagFQN: 'Tier.Tier2', + labelType: 'Manual', + state: 'Confirmed', + }, + }, + ], + headers: { + 'Content-Type': 'application/json-patch+json', + }, + } + ); + + await apiContext.patch(`/api/v1/apps/trigger/DataInsightsApplication`, { data: [ { - op: 'add', - path: '/tags/0', - value: { - name: 'Tier2', - tagFQN: 'Tier.Tier2', - labelType: 'Manual', - state: 'Confirmed', - }, + op: 'remove', + path: '/appConfiguration/backfillConfiguration/startDate', + }, + { + op: 'remove', + path: '/appConfiguration/backfillConfiguration/endDate', + }, + { + op: 'replace', + path: '/batchSize', + value: 1000, + }, + { + op: 'replace', + path: '/recreateDataAssetsIndex', + value: false, + }, + { + op: 'replace', + path: '/backfillConfiguration/enabled', + value: false, }, ], headers: { diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-refresh.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-refresh.svg new file mode 100644 index 000000000000..8737e5347e8c --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-refresh.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/BasicAuthAuthenticator.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/BasicAuthAuthenticator.tsx index 977ab62debea..2faba2a762ef 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/BasicAuthAuthenticator.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/BasicAuthAuthenticator.tsx @@ -15,6 +15,7 @@ import React, { forwardRef, Fragment, ReactNode, + useCallback, useImperativeHandle, } from 'react'; import { useTranslation } from 'react-i18next'; @@ -45,34 +46,33 @@ const BasicAuthenticator = forwardRef( isApplicationLoading, } = useApplicationStore(); - const handleSilentSignIn = async (): Promise => { - const refreshToken = getRefreshToken(); + const handleSilentSignIn = + useCallback(async (): Promise => { + const refreshToken = getRefreshToken(); - if ( - authConfig?.provider !== AuthProvider.Basic && - authConfig?.provider !== AuthProvider.LDAP - ) { - Promise.reject(t('message.authProvider-is-not-basic')); - } + if ( + authConfig?.provider !== AuthProvider.Basic && + authConfig?.provider !== AuthProvider.LDAP + ) { + Promise.reject(t('message.authProvider-is-not-basic')); + } - const response = await getAccessTokenOnExpiry({ - refreshToken: refreshToken as string, - }); + const response = await getAccessTokenOnExpiry({ + refreshToken: refreshToken as string, + }); - setRefreshToken(response.refreshToken); - setOidcToken(response.accessToken); + setRefreshToken(response.refreshToken); + setOidcToken(response.accessToken); - return Promise.resolve(response); - }; + return Promise.resolve(response); + }, [authConfig, getRefreshToken, setOidcToken, setRefreshToken, t]); useImperativeHandle(ref, () => ({ invokeLogout() { handleLogout(); setIsAuthenticated(false); }, - renewIdToken() { - return handleSilentSignIn(); - }, + renewIdToken: handleSilentSignIn, })); /** diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/GenericAuthenticator.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/GenericAuthenticator.tsx index 2afe02205b81..d5264c8b6b27 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/GenericAuthenticator.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/GenericAuthenticator.tsx @@ -49,7 +49,7 @@ export const GenericAuthenticator = forwardRef( const resp = await renewToken(); setOidcToken(resp.accessToken); - return Promise.resolve(resp); + return resp; }; useImperativeHandle(ref, () => ({ diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/MsalAuthenticator.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/MsalAuthenticator.tsx index a4df96c5295a..8c68868e741c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/MsalAuthenticator.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/MsalAuthenticator.tsx @@ -153,6 +153,12 @@ const MsalAuthenticator = forwardRef( } }; + const renewIdToken = async () => { + const user = await fetchIdToken(); + + return user.id_token; + }; + useEffect(() => { const oidcUserToken = getOidcToken(); if ( @@ -177,23 +183,9 @@ const MsalAuthenticator = forwardRef( }, [inProgress, accounts, instance, account]); useImperativeHandle(ref, () => ({ - invokeLogin() { - login(); - }, - invokeLogout() { - logout(); - }, - renewIdToken(): Promise { - return new Promise((resolve, reject) => { - fetchIdToken() - .then((user) => { - resolve(user.id_token); - }) - .catch((e) => { - reject(e); - }); - }); - }, + invokeLogin: login, + invokeLogout: logout, + renewIdToken: renewIdToken, })); return {children}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/OidcAuthenticator.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/OidcAuthenticator.tsx index dedde6e6580a..2470750d32b4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/OidcAuthenticator.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/OidcAuthenticator.tsx @@ -104,15 +104,9 @@ const OidcAuthenticator = forwardRef( }; useImperativeHandle(ref, () => ({ - invokeLogin() { - login(); - }, - invokeLogout() { - logout(); - }, - renewIdToken() { - return signInSilently(); - }, + invokeLogin: login, + invokeLogout: logout, + renewIdToken: signInSilently, })); const AppWithAuth = getAuthenticator(childComponentType, userManager); @@ -167,6 +161,7 @@ const OidcAuthenticator = forwardRef( // eslint-disable-next-line no-console console.error(error); + onLogoutSuccess(); history.push(ROUTES.SIGNIN); }} onSuccess={(user) => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/OktaAuthenticator.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/OktaAuthenticator.tsx index dce1fd375779..759677906a84 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/OktaAuthenticator.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/OktaAuthenticator.tsx @@ -42,22 +42,20 @@ const OktaAuthenticator = forwardRef( onLogoutSuccess(); }; - useImperativeHandle(ref, () => ({ - invokeLogin() { - login(); - }, - invokeLogout() { - logout(); - }, - async renewIdToken() { - const renewToken = await oktaAuth.token.renewTokens(); - oktaAuth.tokenManager.setTokens(renewToken); - const newToken = - renewToken?.idToken?.idToken ?? oktaAuth.getIdToken() ?? ''; - setOidcToken(newToken); + const renewToken = async () => { + const renewToken = await oktaAuth.token.renewTokens(); + oktaAuth.tokenManager.setTokens(renewToken); + const newToken = + renewToken?.idToken?.idToken ?? oktaAuth.getIdToken() ?? ''; + setOidcToken(newToken); + + return Promise.resolve(newToken); + }; - return Promise.resolve(newToken); - }, + useImperativeHandle(ref, () => ({ + invokeLogin: login, + invokeLogout: logout, + renewIdToken: renewToken, })); return {children}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/SamlAuthenticator.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/SamlAuthenticator.tsx index 1f6d7323a737..4f442154ee22 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/SamlAuthenticator.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/SamlAuthenticator.tsx @@ -97,15 +97,9 @@ const SamlAuthenticator = forwardRef( }; useImperativeHandle(ref, () => ({ - invokeLogin() { - login(); - }, - invokeLogout() { - logout(); - }, - async renewIdToken() { - return handleSilentSignIn(); - }, + invokeLogin: login, + invokeLogout: logout, + renewIdToken: handleSilentSignIn, })); return {children}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx index 8e3456cb5086..75ae4d221095 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx @@ -64,6 +64,7 @@ import { fetchAuthorizerConfig, } from '../../../rest/miscAPI'; import { getLoggedInUser, updateUserDetail } from '../../../rest/userAPI'; +import TokenService from '../../../utils/Auth/TokenService/TokenServiceUtil'; import { extractDetailsFromToken, getAuthConfig, @@ -140,6 +141,7 @@ export const AuthProvider = ({ setApplicationLoading, } = useApplicationStore(); const { updateDomains, updateDomainLoading } = useDomainStore(); + const tokenService = useRef(); const location = useCustomLocation(); const history = useHistory(); @@ -183,9 +185,13 @@ export const AuthProvider = ({ setApplicationLoading(false); }, [timeoutId]); - const onRenewIdTokenHandler = () => { - return authenticatorRef.current?.renewIdToken(); - }; + useEffect(() => { + if (authenticatorRef.current?.renewIdToken) { + tokenService.current = new TokenService( + authenticatorRef.current?.renewIdToken + ); + } + }, [authenticatorRef.current?.renewIdToken]); const fetchDomainList = useCallback(async () => { try { @@ -292,8 +298,20 @@ export const AuthProvider = ({ */ const renewIdToken = async () => { try { - const onRenewIdTokenHandlerPromise = onRenewIdTokenHandler(); - onRenewIdTokenHandlerPromise && (await onRenewIdTokenHandlerPromise); + if (!tokenService.current?.isTokenUpdateInProgress()) { + await tokenService.current?.refreshToken(); + } else { + // wait for renewal to complete + const wait = new Promise((resolve) => { + setTimeout(() => { + return resolve(true); + }, 500); + }); + await wait; + + // should have updated token after renewal + return getOidcToken(); + } } catch (error) { // eslint-disable-next-line no-console console.error( @@ -353,8 +371,7 @@ export const AuthProvider = ({ const startTokenExpiryTimer = () => { // Extract expiry const { isExpired, timeoutExpiry } = extractDetailsFromToken( - getOidcToken(), - clientType + getOidcToken() ); const refreshToken = getRefreshToken(); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/NavBar/NavBar.tsx b/openmetadata-ui/src/main/resources/ui/src/components/NavBar/NavBar.tsx index 1bb38fb717e9..81644d1c8c95 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/NavBar/NavBar.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/NavBar/NavBar.tsx @@ -13,7 +13,9 @@ import Icon from '@ant-design/icons'; import { + Alert, Badge, + Button, Col, Dropdown, Input, @@ -45,6 +47,7 @@ import { ReactComponent as DropDownIcon } from '../../assets/svg/drop-down.svg'; import { ReactComponent as IconBell } from '../../assets/svg/ic-alert-bell.svg'; import { ReactComponent as DomainIcon } from '../../assets/svg/ic-domain.svg'; import { ReactComponent as Help } from '../../assets/svg/ic-help.svg'; +import { ReactComponent as RefreshIcon } from '../../assets/svg/ic-refresh.svg'; import { ReactComponent as IconSearch } from '../../assets/svg/search.svg'; import { NOTIFICATION_READ_TIMER, @@ -54,8 +57,10 @@ import { HELP_ITEMS_ENUM } from '../../constants/Navbar.constants'; import { useWebSocketConnector } from '../../context/WebSocketProvider/WebSocketProvider'; import { EntityTabs, EntityType } from '../../enums/entity.enum'; import { useApplicationStore } from '../../hooks/useApplicationStore'; +import useCustomLocation from '../../hooks/useCustomLocation/useCustomLocation'; import { useDomainStore } from '../../hooks/useDomainStore'; import { getVersion } from '../../rest/miscAPI'; +import { isProtectedRoute } from '../../utils/AuthProvider.util'; import brandImageClassBase from '../../utils/BrandImage/BrandImageClassBase'; import { hasNotificationPermission, @@ -108,7 +113,9 @@ const NavBar = ({ const { searchCriteria, updateSearchCriteria } = useApplicationStore(); const searchContainerRef = useRef(null); const Logo = useMemo(() => brandImageClassBase.getMonogram().src, []); - + const [showVersionMissMatchAlert, setShowVersionMissMatchAlert] = + useState(false); + const location = useCustomLocation(); const history = useHistory(); const { domainOptions, @@ -298,7 +305,23 @@ const NavBar = ({ if (shouldRequestPermission()) { Notification.requestPermission(); } - }, []); + + const handleDocumentVisibilityChange = async () => { + if (isProtectedRoute(location.pathname) && isTourRoute) { + return; + } + const newVersion = await getVersion(); + if (version !== newVersion.version) { + setShowVersionMissMatchAlert(true); + } + }; + + addEventListener('focus', handleDocumentVisibilityChange); + + return () => { + removeEventListener('focus', handleDocumentVisibilityChange); + }; + }, [isTourRoute, version]); useEffect(() => { if (socket) { @@ -578,6 +601,26 @@ const NavBar = ({ onCancel={handleModalCancel} /> + {showVersionMissMatchAlert && ( + { + history.go(0); + }}> + {t('label.refresh')} + + } + className="refresh-alert slide-in-top" + description="For a seamless experience recommend you to refresh the page" + icon={} + message="A new version is available" + type="info" + /> + )} {renderAlertCards} ); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/NavBar/nav-bar.less b/openmetadata-ui/src/main/resources/ui/src/components/NavBar/nav-bar.less index 325a0886e98f..511528bd87d0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/NavBar/nav-bar.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/NavBar/nav-bar.less @@ -10,6 +10,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +@import (reference) '../../styles/variables.less'; + .global-search-overlay { .ant-popover-inner-content { padding: 8px; @@ -28,9 +30,53 @@ line-height: 21px; } } + & + .refresh-alert { + width: 470px; + bottom: 30px; + align-items: center; + z-index: 9999; + margin: 0 auto; + box-shadow: 0px 2px 10px rgba(0, 0, 0, 0.12); + border-radius: 10px; + border: 1px solid @text-color; + background: @text-color; + color: @white; + position: fixed; + left: 50%; + transform: translateX(-50%); + + .ant-alert-message { + color: @white; + } + .ant-alert-description { + color: @grey-4; + } + + .ant-alert-content { + border-right: 1px solid @white; + } + + .ant-btn { + font-weight: 700; + color: @white; + } + } } .domain-dropdown-menu { max-height: 400px; overflow-y: auto; } + +.slide-in-top { + animation: slide-in-top 1s cubic-bezier(0.25, 0.46, 0.45, 0.94) both; +} + +@keyframes slide-in-top { + 0% { + transform: translate(-50%, 100%); + } + 100% { + transform: translate(-50%, 0); + } +} diff --git a/openmetadata-ui/src/main/resources/ui/src/setupTests.js b/openmetadata-ui/src/main/resources/ui/src/setupTests.js index f616486b926f..cfcda80d8c69 100644 --- a/openmetadata-ui/src/main/resources/ui/src/setupTests.js +++ b/openmetadata-ui/src/main/resources/ui/src/setupTests.js @@ -55,6 +55,13 @@ window.DOMMatrixReadOnly = jest.fn().mockImplementation(() => ({ isIdentity: true, })); +window.BroadcastChannel = jest.fn().mockImplementation(() => ({ + postMessage: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + close: jest.fn(), +})); + /** * mock implementation of ResizeObserver */ diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/Auth/TokenService/TokenServiceUtil.ts b/openmetadata-ui/src/main/resources/ui/src/utils/Auth/TokenService/TokenServiceUtil.ts new file mode 100644 index 000000000000..4c028bd1724d --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/Auth/TokenService/TokenServiceUtil.ts @@ -0,0 +1,91 @@ +/* + * Copyright 2024 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { useApplicationStore } from '../../../hooks/useApplicationStore'; +import { AccessTokenResponse } from '../../../rest/auth-API'; +import { extractDetailsFromToken } from '../../AuthProvider.util'; +import { getOidcToken } from '../../LocalStorageUtils'; + +class TokenService { + channel: BroadcastChannel; + renewToken: () => Promise | Promise; + tokeUpdateInProgress: boolean; + + constructor( + renewToken: () => Promise | Promise + ) { + this.channel = new BroadcastChannel('auth_channel'); + this.renewToken = renewToken; + this.channel.onmessage = this.handleTokenUpdate.bind(this); + this.tokeUpdateInProgress = false; + } + + // This method will update token across tabs on receiving message to the channel + handleTokenUpdate(event: { + data: { type: string; token: string | AccessTokenResponse }; + }) { + const { + data: { type, token }, + } = event; + if (type === 'TOKEN_UPDATE' && token) { + if (typeof token !== 'string') { + useApplicationStore.getState().setOidcToken(token.accessToken); + useApplicationStore.getState().setRefreshToken(token.refreshToken); + useApplicationStore.getState().updateAxiosInterceptors(); + } else { + useApplicationStore.getState().setOidcToken(token); + } + } + } + + // Refresh the token if it is expired + async refreshToken() { + const token = getOidcToken(); + const { isExpired } = extractDetailsFromToken(token); + + if (isExpired) { + // Logic to refresh the token + const newToken = await this.fetchNewToken(); + // To update all the tabs on updating channel token + this.channel.postMessage({ type: 'TOKEN_UPDATE', token: newToken }); + + return newToken; + } else { + return token; + } + } + + // Call renewal method according to the provider + async fetchNewToken() { + let response: string | AccessTokenResponse | null = null; + if (typeof this.renewToken === 'function') { + try { + this.tokeUpdateInProgress = true; + response = await this.renewToken(); + this.tokeUpdateInProgress = false; + } catch (error) { + // Do nothing + } finally { + this.tokeUpdateInProgress = false; + } + } + + return response; + } + + // Tracker for any ongoing token update + isTokenUpdateInProgress() { + return this.tokeUpdateInProgress; + } +} + +export default TokenService; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/AuthProvider.util.ts b/openmetadata-ui/src/main/resources/ui/src/utils/AuthProvider.util.ts index 631461b0c6ed..25d8656f0f29 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/AuthProvider.util.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/AuthProvider.util.ts @@ -36,11 +36,8 @@ import { isDev } from './EnvironmentUtils'; const cookieStorage = new CookieStorage(); -// 25s for server auth approach -export const EXPIRY_THRESHOLD_MILLES = 25 * 1000; - -// 2 minutes for client auth approach -export const EXPIRY_THRESHOLD_MILLES_PUBLIC = 2 * 60 * 1000; +// 1 minutes for client auth approach +export const EXPIRY_THRESHOLD_MILLES = 1 * 60 * 1000; const subPath = process.env.APP_SUB_PATH ?? ''; @@ -330,10 +327,7 @@ export const getUrlPathnameExpiry = () => { * @timeoutExpiry time in ms for try to silent sign-in * @returns exp, isExpired, diff, timeoutExpiry */ -export const extractDetailsFromToken = ( - token: string, - clientType = ClientType.Public -) => { +export const extractDetailsFromToken = (token: string) => { if (token) { try { const { exp } = jwtDecode(token); @@ -345,10 +339,7 @@ export const extractDetailsFromToken = ( isExpired: false, }; } - const threshouldMillis = - clientType === ClientType.Public - ? EXPIRY_THRESHOLD_MILLES_PUBLIC - : EXPIRY_THRESHOLD_MILLES; + const threshouldMillis = EXPIRY_THRESHOLD_MILLES; const diff = exp && exp * 1000 - dateNow; const timeoutExpiry =