feat(ui): support refresh with multi tabs open (#17782)

* feat(ui): support refresh with multi tabs open

* address comments

* fix clientType error

* fix unit tests

* add version miss match alert for seamless experience

* update variable values

* use custom hook for location

* patch DataInsight app before running it

* fix addRoldeAndAssignToUser flaky test failure

(cherry picked from commit 17744f42dc5e71f8e3ca26e2b076b9d72a11556b)
This commit is contained in:
Chirag Madlani 2024-09-13 14:16:42 +05:30 committed by Chira Madlani
parent b72513cf03
commit f08e643152
14 changed files with 318 additions and 100 deletions

View File

@ -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: {

View File

@ -0,0 +1,10 @@
<svg width="34" height="34" viewBox="0 0 34 34" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_9647_49527)">
<path d="M15.6719 23.4391V13.8251L12.9884 16.5767C12.4763 17.1019 11.6355 17.1124 11.1103 16.6003C10.5852 16.0882 10.5747 15.2473 11.0868 14.7222L16.0493 9.63362C16.2992 9.37736 16.6421 9.23279 17.0001 9.23279C17.3581 9.23279 17.701 9.37736 17.9509 9.63362L22.9133 14.7222C23.4255 15.2473 23.4149 16.0882 22.8898 16.6003C22.6316 16.8521 22.297 16.9776 21.9627 16.9776C21.6172 16.9776 21.272 16.8437 21.0117 16.5767L18.3281 13.8251V23.4391C18.3281 24.1727 17.7335 24.7673 17 24.7673C16.2665 24.7673 15.6719 24.1726 15.6719 23.4391ZM29.0209 4.97914C25.8099 1.76833 21.5409 0 17 0C12.4591 0 8.19008 1.76833 4.97914 4.97914C1.76833 8.19008 0 12.4591 0 17C0 21.5409 1.76833 25.8099 4.97914 29.0208C8.19008 32.2317 12.4591 34 17 34C20.3597 34 23.6088 33.0212 26.3962 31.1693C27.0072 30.7634 27.1734 29.9391 26.7675 29.3281C26.3616 28.7172 25.5373 28.5508 24.9263 28.9568C22.5759 30.5183 19.835 31.3438 17 31.3438C9.09082 31.3438 2.65625 24.9092 2.65625 17C2.65625 9.09082 9.09082 2.65625 17 2.65625C24.9092 2.65625 31.3438 9.09082 31.3438 17C31.3438 19.6974 30.5913 22.3251 29.1677 24.5993C28.7785 25.221 28.9671 26.0405 29.5888 26.4297C30.2102 26.8188 31.03 26.6304 31.4192 26.0086C33.1076 23.3114 34 20.1963 34 17C34 12.4591 32.2317 8.19008 29.0209 4.97914Z" fill="#48CA9E"/>
</g>
<defs>
<clipPath id="clip0_9647_49527">
<rect width="34" height="34" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -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<AccessTokenResponse> => {
const refreshToken = getRefreshToken();
const handleSilentSignIn =
useCallback(async (): Promise<AccessTokenResponse> => {
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,
}));
/**

View File

@ -49,7 +49,7 @@ export const GenericAuthenticator = forwardRef(
const resp = await renewToken();
setOidcToken(resp.accessToken);
return Promise.resolve(resp);
return resp;
};
useImperativeHandle(ref, () => ({

View File

@ -153,6 +153,12 @@ const MsalAuthenticator = forwardRef<AuthenticatorRef, Props>(
}
};
const renewIdToken = async () => {
const user = await fetchIdToken();
return user.id_token;
};
useEffect(() => {
const oidcUserToken = getOidcToken();
if (
@ -177,23 +183,9 @@ const MsalAuthenticator = forwardRef<AuthenticatorRef, Props>(
}, [inProgress, accounts, instance, account]);
useImperativeHandle(ref, () => ({
invokeLogin() {
login();
},
invokeLogout() {
logout();
},
renewIdToken(): Promise<string> {
return new Promise((resolve, reject) => {
fetchIdToken()
.then((user) => {
resolve(user.id_token);
})
.catch((e) => {
reject(e);
});
});
},
invokeLogin: login,
invokeLogout: logout,
renewIdToken: renewIdToken,
}));
return <Fragment>{children}</Fragment>;

View File

@ -102,15 +102,9 @@ const OidcAuthenticator = forwardRef<AuthenticatorRef, Props>(
};
useImperativeHandle(ref, () => ({
invokeLogin() {
login();
},
invokeLogout() {
logout();
},
renewIdToken() {
return signInSilently();
},
invokeLogin: login,
invokeLogout: logout,
renewIdToken: signInSilently,
}));
const AppWithAuth = getAuthenticator(childComponentType, userManager);
@ -165,6 +159,7 @@ const OidcAuthenticator = forwardRef<AuthenticatorRef, Props>(
// eslint-disable-next-line no-console
console.error(error);
onLogoutSuccess();
history.push(ROUTES.SIGNIN);
}}
onSuccess={(user) => {

View File

@ -42,22 +42,20 @@ const OktaAuthenticator = forwardRef<AuthenticatorRef, Props>(
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 <Fragment>{children}</Fragment>;

View File

@ -97,15 +97,9 @@ const SamlAuthenticator = forwardRef<AuthenticatorRef, Props>(
};
useImperativeHandle(ref, () => ({
invokeLogin() {
login();
},
invokeLogout() {
logout();
},
async renewIdToken() {
return handleSilentSignIn();
},
invokeLogin: login,
invokeLogout: logout,
renewIdToken: handleSilentSignIn,
}));
return <Fragment>{children}</Fragment>;

View File

@ -63,6 +63,7 @@ import {
fetchAuthorizerConfig,
} from '../../../rest/miscAPI';
import { getLoggedInUser, updateUserDetail } from '../../../rest/userAPI';
import TokenService from '../../../utils/Auth/TokenService/TokenServiceUtil';
import {
extractDetailsFromToken,
getAuthConfig,
@ -138,6 +139,7 @@ export const AuthProvider = ({
setApplicationLoading,
} = useApplicationStore();
const { updateDomains, updateDomainLoading } = useDomainStore();
const tokenService = useRef<TokenService>();
const location = useLocation();
const history = useHistory();
@ -181,9 +183,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 {
@ -290,8 +296,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(
@ -351,8 +369,7 @@ export const AuthProvider = ({
const startTokenExpiryTimer = () => {
// Extract expiry
const { isExpired, timeoutExpiry } = extractDetailsFromToken(
getOidcToken(),
clientType
getOidcToken()
);
const refreshToken = getRefreshToken();

View File

@ -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<HTMLDivElement>(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 && (
<Alert
showIcon
action={
<Button
size="small"
type="link"
onClick={() => {
history.go(0);
}}>
{t('label.refresh')}
</Button>
}
className="refresh-alert slide-in-top"
description="For a seamless experience recommend you to refresh the page"
icon={<RefreshIcon />}
message="A new version is available"
type="info"
/>
)}
{renderAlertCards}
</>
);

View File

@ -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);
}
}

View File

@ -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
*/

View File

@ -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<string> | Promise<AccessTokenResponse>;
tokeUpdateInProgress: boolean;
constructor(
renewToken: () => Promise<string> | Promise<AccessTokenResponse>
) {
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;

View File

@ -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;
export const getRedirectUri = (callbackUrl: string) => {
return isDev()
@ -328,10 +325,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<JwtPayload>(token);
@ -343,10 +337,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 =