Skip to content

Commit 4fa5f4b

Browse files
committed
feat(ui): add multi-cluster user logout
- Visual support for displaying authenticated users per cluster in TopBar. - Implemented individual cluster logout functionality. - Added SelfSubjectReview API support for fetching user info. - Fixed multi-cluster selection state bug in useSelectedClusters hook. - Improved logout UX to persist view on remaining clusters. Fixes #4082 Signed-off-by: alokdangre <[email protected]>
1 parent d9a3326 commit 4fa5f4b

File tree

3 files changed

+203
-98
lines changed

3 files changed

+203
-98
lines changed

frontend/src/components/App/TopBar.tsx

Lines changed: 143 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -17,24 +17,26 @@
1717
import { Icon } from '@iconify/react';
1818
import AppBar from '@mui/material/AppBar';
1919
import Box from '@mui/material/Box';
20+
import Divider from '@mui/material/Divider';
2021
import IconButton from '@mui/material/IconButton';
2122
import ListItemIcon from '@mui/material/ListItemIcon';
2223
import ListItemText from '@mui/material/ListItemText';
2324
import Menu from '@mui/material/Menu';
2425
import MenuItem from '@mui/material/MenuItem';
2526
import { useTheme } from '@mui/material/styles';
2627
import Toolbar from '@mui/material/Toolbar';
28+
import Typography from '@mui/material/Typography';
2729
import useMediaQuery from '@mui/material/useMediaQuery';
28-
import { useQuery, useQueryClient } from '@tanstack/react-query';
30+
import { useQueries, useQueryClient } from '@tanstack/react-query';
2931
import { has } from 'lodash';
3032
import React, { memo, useCallback } from 'react';
3133
import { useTranslation } from 'react-i18next';
3234
import { useDispatch } from 'react-redux';
3335
import { useHistory } from 'react-router-dom';
3436
import { getProductName, getVersion } from '../../helpers/getProductInfo';
3537
import { logout } from '../../lib/auth';
36-
import { useCluster, useClustersConf } from '../../lib/k8s';
37-
import { clusterRequest } from '../../lib/k8s/api/v1/clusterRequests';
38+
import { useCluster, useClustersConf, useSelectedClusters } from '../../lib/k8s';
39+
import { ClusterUserInfo, getClusterUserInfo } from '../../lib/k8s/api/v1/clusterApi';
3840
import { createRouteURL } from '../../lib/router/createRouteURL';
3941
import {
4042
AppBarAction,
@@ -86,70 +88,48 @@ export default function TopBar({}: TopBarProps) {
8688

8789
const clustersConfig = useClustersConf();
8890
const cluster = useCluster();
91+
const selectedClusters = useSelectedClusters();
8992
const history = useHistory();
9093
const { appBarActions, appBarActionsProcessors } = useAppBarActionsProcessed();
9194
const queryClient = useQueryClient();
92-
const clusterName = cluster ?? undefined;
93-
const { data: me } = useQuery<{ username?: string; email?: string } | null>({
94-
queryKey: ['clusterMe', clusterName],
95-
queryFn: async () => {
96-
if (!clusterName) {
97-
return null;
98-
}
99-
100-
try {
101-
const res = await clusterRequest('/me', {
102-
cluster: clusterName,
103-
autoLogoutOnAuthError: false,
104-
});
105-
106-
if (!res) {
107-
return null;
108-
}
10995

110-
if (!(typeof res.userInfoURL === 'string' && res.userInfoURL.length > 0)) {
111-
return { username: res.username, email: res.email };
112-
}
113-
114-
const ui: {
115-
preferredUsername?: string;
116-
email?: string;
117-
} = await fetch(res.userInfoURL, {
118-
credentials: 'include',
119-
headers: {
120-
'Content-Type': 'application/json',
121-
},
122-
}).then(r => {
123-
if (!r.ok) {
124-
throw new Error(`Could not fetch user info from ${res.userInfoURL}`);
96+
const logoutCallback = useCallback(
97+
async (clusterToLogout?: string) => {
98+
if (clusterToLogout) {
99+
await logout(clusterToLogout);
100+
queryClient.removeQueries({ queryKey: ['clusterMe', clusterToLogout], exact: true });
101+
102+
if (selectedClusters.length > 1) {
103+
const remainingClusters = selectedClusters.filter(c => c !== clusterToLogout);
104+
if (remainingClusters.length > 0) {
105+
const currentPath = history.location.pathname;
106+
const clusterParam = selectedClusters.join('+');
107+
const newClusterParam = remainingClusters.join('+');
108+
const newPath = currentPath.replace(`/c/${clusterParam}`, `/c/${newClusterParam}`);
109+
history.push(newPath);
110+
} else {
111+
history.push('/');
125112
}
126-
return r.json();
127-
});
128-
129-
if (!ui || (!ui.preferredUsername && !ui.email)) {
130-
return null;
113+
} else {
114+
history.push('/');
131115
}
132-
133-
return {
134-
username: ui.preferredUsername,
135-
email: ui.email,
136-
};
137-
} catch {
138-
return null;
116+
} else {
117+
if (selectedClusters.length > 0) {
118+
await Promise.all(
119+
selectedClusters.map(async c => {
120+
await logout(c);
121+
queryClient.removeQueries({ queryKey: ['clusterMe', c], exact: true });
122+
})
123+
);
124+
} else if (cluster) {
125+
await logout(cluster);
126+
queryClient.removeQueries({ queryKey: ['clusterMe', cluster], exact: true });
127+
}
128+
history.push('/');
139129
}
140130
},
141-
enabled: Boolean(clusterName),
142-
staleTime: 0,
143-
refetchOnMount: 'always',
144-
});
145-
146-
const logoutCallback = useCallback(async () => {
147-
if (!!cluster) {
148-
await logout(cluster);
149-
queryClient.removeQueries({ queryKey: ['clusterMe', cluster], exact: true });
150-
}
151-
history.push('/');
152-
}, [cluster, history, queryClient]);
131+
[cluster, selectedClusters, history, queryClient]
132+
);
153133

154134
const handletoggleOpen = useCallback(() => {
155135
// For medium view we default to closed if they have not made a selection.
@@ -174,8 +154,8 @@ export default function TopBar({}: TopBarProps) {
174154
isSidebarOpenUserSelected={isSidebarOpenUserSelected}
175155
onToggleOpen={handletoggleOpen}
176156
cluster={cluster || undefined}
157+
selectedClusters={selectedClusters}
177158
clusters={clustersConfig || undefined}
178-
userInfo={me || undefined}
179159
/>
180160
);
181161
}
@@ -185,14 +165,14 @@ export interface PureTopBarProps {
185165
appBarActions: AppBarAction[];
186166
/** functions which filter the app bar action buttons */
187167
appBarActionsProcessors?: AppBarActionsProcessor[];
188-
logout: () => Promise<any> | void;
168+
logout: (cluster?: string) => Promise<any> | void;
189169
clusters?: {
190170
[clusterName: string]: any;
191171
};
192172
cluster?: string;
173+
selectedClusters?: string[];
193174
isSidebarOpen?: boolean;
194175
isSidebarOpenUserSelected?: boolean;
195-
userInfo?: { username?: string; email?: string };
196176

197177
/** Called when sidebar toggles between open and closed. */
198178
onToggleOpen: () => void;
@@ -265,11 +245,11 @@ export const PureTopBar = memo(
265245
appBarActionsProcessors = [],
266246
logout,
267247
cluster,
248+
selectedClusters,
268249
clusters,
269250
isSidebarOpen,
270251
isSidebarOpenUserSelected,
271252
onToggleOpen,
272-
userInfo,
273253
}: PureTopBarProps) => {
274254
const { t } = useTranslation();
275255
const theme = useTheme();
@@ -303,13 +283,37 @@ export const PureTopBar = memo(
303283
setMobileMoreAnchorEl(event.currentTarget);
304284
};
305285
const userMenuId = 'primary-user-menu';
306-
const userDisplayName = userInfo?.username || userInfo?.email || '';
307-
const userSecondaryInfo =
308-
userInfo?.username && userInfo?.email && userInfo.username !== userInfo.email
309-
? userInfo.email
310-
: undefined;
311286

312-
const renderUserMenu = !!isClusterContext && (
287+
const clustersToQuery =
288+
selectedClusters && selectedClusters.length > 0 ? selectedClusters : cluster ? [cluster] : [];
289+
290+
const userInfoQueries = useQueries({
291+
queries: clustersToQuery.map(clusterName => ({
292+
queryKey: ['clusterMe', clusterName],
293+
queryFn: () => getClusterUserInfo(clusterName),
294+
staleTime: 5 * 60 * 1000,
295+
retry: 1,
296+
})),
297+
});
298+
299+
const clusterUserInfoMap: Record<string, ClusterUserInfo | undefined> = {};
300+
clustersToQuery.forEach((clusterName, index) => {
301+
if (userInfoQueries[index]?.data) {
302+
clusterUserInfoMap[clusterName] = userInfoQueries[index].data;
303+
}
304+
});
305+
306+
const getUserDisplayName = (clusterName: string): string => {
307+
const userInfo = clusterUserInfoMap[clusterName];
308+
if (userInfo?.username) {
309+
return userInfo.username;
310+
}
311+
return clusterName;
312+
};
313+
314+
const showUserMenu = (!!selectedClusters && selectedClusters.length > 0) || isClusterContext;
315+
316+
const renderUserMenu = showUserMenu && (
313317
<Menu
314318
anchorEl={anchorEl}
315319
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
@@ -327,38 +331,83 @@ export const PureTopBar = memo(
327331
},
328332
}}
329333
>
330-
{!!userDisplayName && (
334+
{selectedClusters && selectedClusters.length > 1 && (
331335
<MenuItem
332-
disableRipple
333-
sx={{
334-
pointerEvents: 'none',
335-
cursor: 'default',
336-
'&:hover': { backgroundColor: 'inherit' },
336+
component="a"
337+
onClick={async () => {
338+
await logout();
339+
handleMenuClose();
337340
}}
338341
>
339342
<ListItemIcon>
340-
<Icon icon="mdi:account" />
343+
<Icon icon="mdi:logout" />
344+
</ListItemIcon>
345+
<ListItemText primary={t('Log out from all')} />
346+
</MenuItem>
347+
)}
348+
349+
{selectedClusters && selectedClusters.length > 1 ? (
350+
selectedClusters.map(clusterName => {
351+
const userName = getUserDisplayName(clusterName);
352+
return (
353+
<MenuItem
354+
key={`logout-${clusterName}`}
355+
component="a"
356+
onClick={async () => {
357+
await logout(clusterName);
358+
handleMenuClose();
359+
}}
360+
>
361+
<ListItemIcon>
362+
<Icon icon="mdi:logout" />
363+
</ListItemIcon>
364+
<ListItemText
365+
primary={
366+
<Box display="flex" flexDirection="column">
367+
<Typography variant="body2" component="span">
368+
{t('Log out')}: {userName}
369+
</Typography>
370+
<Typography variant="caption" color="text.secondary" component="span">
371+
{clusterName}
372+
</Typography>
373+
</Box>
374+
}
375+
/>
376+
</MenuItem>
377+
);
378+
})
379+
) : (
380+
<MenuItem
381+
component="a"
382+
onClick={async () => {
383+
await logout();
384+
handleMenuClose();
385+
}}
386+
>
387+
<ListItemIcon>
388+
<Icon icon="mdi:logout" />
341389
</ListItemIcon>
342390
<ListItemText
343-
primaryTypographyProps={{ variant: 'subtitle2' }}
344-
secondaryTypographyProps={{ variant: 'body2' }}
345-
primary={userDisplayName}
346-
secondary={userSecondaryInfo}
391+
primary={
392+
cluster ? (
393+
<Box display="flex" flexDirection="column">
394+
<Typography variant="body2" component="span">
395+
{t('Log out')}: {getUserDisplayName(cluster)}
396+
</Typography>
397+
<Typography variant="caption" color="text.secondary" component="span">
398+
{cluster}
399+
</Typography>
400+
</Box>
401+
) : (
402+
t('Log out')
403+
)
404+
}
347405
/>
348406
</MenuItem>
349407
)}
350-
<MenuItem
351-
component="a"
352-
onClick={async () => {
353-
await logout();
354-
handleMenuClose();
355-
}}
356-
>
357-
<ListItemIcon>
358-
<Icon icon="mdi:logout" />
359-
</ListItemIcon>
360-
<ListItemText primary={t('Log out')} />
361-
</MenuItem>
408+
409+
<Divider />
410+
362411
<MenuItem
363412
component="a"
364413
onClick={() => {
@@ -411,7 +460,7 @@ export const PureTopBar = memo(
411460
},
412461
{
413462
id: DefaultAppBarAction.USER,
414-
action: !!isClusterContext && (
463+
action: showUserMenu && (
415464
<MenuItem>
416465
<IconButton
417466
aria-label={t('Account of current user')}
@@ -470,7 +519,7 @@ export const PureTopBar = memo(
470519
},
471520
{
472521
id: DefaultAppBarAction.USER,
473-
action: !!isClusterContext && (
522+
action: showUserMenu && (
474523
<IconButton
475524
aria-label={t('Account of current user')}
476525
aria-controls={userMenuId}

0 commit comments

Comments
 (0)