Skip to content

Commit 28bb644

Browse files
committed
feat(FR-2209): control header project selector, confirm admin-mode switch, and fix sider go-back on admin pages (#6656)
Resolves #6645 (FR-2553) Part of FR-2209 Project Admin Management stack (PR-1b). Stacks on #6653. ## Summary - In admin mode, when a project-admin user selects a project they are not an admin of in the header `ProjectSelect`, a confirm modal asks before exiting admin mode - Confirm modal content includes the target project name (new `{{projectName}}` placeholder in `header.SwitchOutOfAdminConfirmContent`) - While the modal is open, the selector optimistically shows the target project with a loading state; cancel reverts to the current project, confirm commits and navigates back to the last-visited general page - Fix sider "go back" button silently doing nothing on admin category pages (e.g. `/project-admin-users`) for users whose effective admin role filters the page out of their admin menu — superadmin visiting via URL, or a project-admin whose current project lacks admin rights. Added a role-independent `isCurrentPathAdminCategory` flag (derived from `ALL_ADMIN_PAGE_KEYS` + `PROJECT_ADMIN_PAGE_KEY_SET` + `storage-settings`) and used it to guard `goBackPath` writes, so an admin path is never stored as the "last visited general page" - Simplify `useCurrentUserProjectRoles`: drop `isProjectAdminForId`/`deriveProjectAdminIds` helpers, `MyRolesAssignmentNode` shape, and `rawAssignments`. `projectAdminIds` now contains full project UUIDs sourced from `role.scopes` (new backend schema), so call sites use plain `Array.includes` against `useCurrentProject().id`. `useEffectiveAdminRole` correctly compares `currentProjectId` against this simplified list. - Translate the two new `header.*` keys into all 19 target languages - Regenerate `data/schema.graphql` against core 26.4.1 (adds `AddRevisionOptions` and updates version annotations) ## Verification - `bash scripts/verify.sh` → `=== ALL PASS ===` ## Test plan - [ ] As a project-admin user, enter admin mode and switch the header project selector to a project where you are not an admin → confirm modal appears with the target project name - [ ] Cancel the modal → selector reverts to the previously selected project, no navigation occurs - [ ] Confirm the modal → project switches, admin mode exits, UI navigates to the last-visited general page - [ ] Repeat for superadmin and domain admin: the selector remains visible and switching does not trigger the modal (no regression) - [ ] On `/project-admin-users` as a project admin, click the sider "go back" button → navigates to the previously visited general page (not the same admin page) - [ ] Visit `/project-admin-users` as a superadmin via URL, navigate to `/session`, then enter admin mode and use "go back" → returns to `/session` (not `/project-admin-users`)
1 parent 780db50 commit 28bb644

28 files changed

Lines changed: 855 additions & 786 deletions

data/schema.graphql

Lines changed: 632 additions & 434 deletions
Large diffs are not rendered by default.

react/src/components/MainLayout/WebUIHeader.tsx

Lines changed: 85 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,26 @@
55
import {
66
useCurrentDomainValue,
77
useSuspendedBackendaiClient,
8+
useWebUINavigate,
89
} from '../../hooks';
910
import {
1011
useCurrentProjectValue,
1112
useSetCurrentProject,
1213
} from '../../hooks/useCurrentProject';
14+
import {
15+
useCurrentUserProjectRoles,
16+
useEffectiveAdminRole,
17+
} from '../../hooks/useCurrentUserProjectRoles';
18+
import { useWebUIMenuItems } from '../../hooks/useWebUIMenuItems';
1319
import BAINotificationButton from '../BAINotificationButton';
1420
import LoginSessionExtendButton from '../LoginSessionExtendButton';
1521
import ProjectSelect from '../ProjectSelect';
1622
import ReverseThemeProvider from '../ReverseThemeProvider';
1723
import UserDropdownMenu from '../UserDropdownMenu';
1824
import WEBUIHelpButton from '../WEBUIHelpButton';
1925
import WebUIThemeToggleButton from '../WebUIThemeToggleButton';
20-
import { theme, Button, Typography, Grid, Divider } from 'antd';
26+
import { useSessionStorageState } from 'ahooks';
27+
import { theme, Button, Modal, Typography, Grid, Divider } from 'antd';
2128
import { createStyles } from 'antd-style';
2229
import { BAIFlex, BAIFlexProps } from 'backend.ai-ui';
2330
import { MenuIcon } from 'lucide-react';
@@ -48,12 +55,43 @@ const WebUIHeader: React.FC<WebUIHeaderProps> = ({ onClickMenuIcon }) => {
4855
const setCurrentProject = useSetCurrentProject();
4956
const baiClient = useSuspendedBackendaiClient();
5057
const gridBreakpoint = Grid.useBreakpoint();
58+
const webuiNavigate = useWebUINavigate();
59+
const { isSelectedAdminCategoryMenu, defaultMenuPath } = useWebUIMenuItems();
60+
const effectiveAdminRole = useEffectiveAdminRole();
61+
const { projectAdminIds } = useCurrentUserProjectRoles();
62+
63+
// Last visited general page — shared with WebUISider's "go back" button so
64+
// that exiting admin mode returns the user to where they were last. See
65+
// WebUISider.tsx (`backendaiwebui.last_visited_general_path`).
66+
const [goBackPath] = useSessionStorageState<string | undefined>(
67+
'backendaiwebui.last_visited_general_path',
68+
);
5169

5270
const [isPendingProjectChanged, startProjectChangedTransition] =
5371
useTransition();
5472
const [optimisticProjectId, setOptimisticProjectId] = useState(
5573
currentProject.id,
5674
);
75+
// Tracks whether the admin-exit confirm modal is currently open. While open,
76+
// the select optimistically shows the target project and a loading state,
77+
// even though we haven't committed the change yet.
78+
const [isConfirmingProjectSwitch, setIsConfirmingProjectSwitch] =
79+
useState(false);
80+
const isProjectChanging =
81+
isPendingProjectChanged || isConfirmingProjectSwitch;
82+
83+
const [modal, modalContextHolder] = Modal.useModal();
84+
85+
const applyProjectChange = (projectInfo: {
86+
projectId: string;
87+
projectName: string;
88+
projectResourcePolicy: unknown;
89+
}) => {
90+
setOptimisticProjectId(projectInfo.projectId);
91+
startProjectChangedTransition(() => {
92+
setCurrentProject(projectInfo);
93+
});
94+
};
5795

5896
const { styles } = useStyles();
5997

@@ -107,19 +145,55 @@ const WebUIHeader: React.FC<WebUIHeaderProps> = ({ onClickMenuIcon }) => {
107145
minWidth: 100,
108146
maxWidth: gridBreakpoint.lg ? undefined : 150,
109147
}}
110-
loading={isPendingProjectChanged}
111-
disabled={isPendingProjectChanged}
148+
loading={isProjectChanging}
149+
disabled={isProjectChanging}
112150
className="non-draggable"
113151
showSearch
114152
domain={currentDomainName}
115-
value={
116-
isPendingProjectChanged ? optimisticProjectId : currentProject?.id
117-
}
153+
value={isProjectChanging ? optimisticProjectId : currentProject?.id}
118154
onSelectProject={(projectInfo) => {
119-
setOptimisticProjectId(projectInfo.projectId);
120-
startProjectChangedTransition(() => {
121-
setCurrentProject(projectInfo);
122-
});
155+
const isTargetProjectAdmin = projectAdminIds.includes(
156+
projectInfo.projectId,
157+
);
158+
159+
// In admin mode, switching to a project the user is NOT a
160+
// project-admin of means leaving admin mode. Confirm first so
161+
// the user doesn't accidentally lose their admin context.
162+
if (
163+
isSelectedAdminCategoryMenu &&
164+
effectiveAdminRole === 'currentProjectAdmin' &&
165+
!isTargetProjectAdmin
166+
) {
167+
// Optimistically show the target project (with loading) while
168+
// the confirm modal is open, so the user sees where they are
169+
// about to switch to.
170+
setOptimisticProjectId(projectInfo.projectId);
171+
setIsConfirmingProjectSwitch(true);
172+
modal.confirm({
173+
title: t('header.SwitchOutOfAdminConfirmTitle'),
174+
content: t('header.SwitchOutOfAdminConfirmContent', {
175+
projectName: projectInfo.projectName,
176+
}),
177+
okText: t('button.Confirm'),
178+
cancelText: t('button.Cancel'),
179+
onOk: () => {
180+
setIsConfirmingProjectSwitch(false);
181+
applyProjectChange(projectInfo);
182+
// Exit admin mode by navigating to the last-visited
183+
// general page (or the default menu path as fallback).
184+
webuiNavigate(goBackPath || defaultMenuPath);
185+
},
186+
onCancel: () => {
187+
// Revert the optimistic selection back to the current
188+
// project so the dropdown reflects the unchanged state.
189+
setIsConfirmingProjectSwitch(false);
190+
setOptimisticProjectId(currentProject.id);
191+
},
192+
});
193+
return;
194+
}
195+
196+
applyProjectChange(projectInfo);
123197
}}
124198
/>
125199
</Suspense>
@@ -163,6 +237,7 @@ const WebUIHeader: React.FC<WebUIHeaderProps> = ({ onClickMenuIcon }) => {
163237
}}
164238
/>
165239
</BAIFlex>
240+
{modalContextHolder}
166241
</BAIFlex>
167242
);
168243
};

react/src/components/MainLayout/WebUISider.tsx

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import {
2727
} from 'antd';
2828
import { filterOutEmpty, BAIFlex } from 'backend.ai-ui';
2929
import * as _ from 'lodash-es';
30-
import { ArrowLeftIcon, SettingsIcon } from 'lucide-react';
30+
import { ArrowLeftIcon, ShieldUserIcon } from 'lucide-react';
3131
import React, { useContext, useEffect, useRef } from 'react';
3232
import { useTranslation } from 'react-i18next';
3333
import { useLocation } from 'react-router-dom';
@@ -71,6 +71,7 @@ const WebUISider: React.FC<WebUISiderProps> = (props) => {
7171
groupedGeneralMenu,
7272
groupedAdminMenu,
7373
isSelectedAdminCategoryMenu,
74+
isCurrentPathAdminCategory,
7475
isCurrentPageUnauthorized,
7576
firstAvailableAdminMenuItem,
7677
defaultMenuPath,
@@ -82,12 +83,19 @@ const WebUISider: React.FC<WebUISiderProps> = (props) => {
8283
'backendaiwebui.last_visited_general_path',
8384
);
8485

85-
// Store the last visited general menu path when the admin category menu is not selected
86+
// Store the last visited general (non-admin) menu path so the admin header's
87+
// "go back" button can return the user to where they were. Use the role-
88+
// independent `isCurrentPathAdminCategory` instead of
89+
// `isSelectedAdminCategoryMenu`: the latter is role-filtered and would
90+
// misclassify an admin page as "general" for users whose admin menu
91+
// excludes that page (e.g. superadmin on `/project-admin-users`), which
92+
// would pollute `goBackPath` with an admin path and make a later go-back
93+
// navigate to the same page.
8694
useEffect(() => {
87-
if (isSelectedAdminCategoryMenu === false) {
95+
if (!isCurrentPathAdminCategory) {
8896
setGoBackPath(location.pathname);
8997
}
90-
}, [setGoBackPath, location.pathname, isSelectedAdminCategoryMenu]);
98+
}, [setGoBackPath, location.pathname, isCurrentPathAdminCategory]);
9199

92100
const adminHeader = (
93101
<BAIFlex align="center">
@@ -222,7 +230,7 @@ const WebUISider: React.FC<WebUISiderProps> = (props) => {
222230
{t('webui.menu.AdminSettings')}
223231
</WebUILink>
224232
),
225-
icon: <SettingsIcon style={{ color: token.colorInfo }} />,
233+
icon: <ShieldUserIcon style={{ color: token.colorInfo }} />,
226234
key: 'admin-settings',
227235
},
228236
...groupedGeneralMenu,

react/src/components/ProjectSelect.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,7 @@ import { ProjectSelectorQuery } from '../__generated__/ProjectSelectorQuery.grap
66
import { useSuspendedBackendaiClient } from '../hooks';
77
import { useCurrentUserInfo, useCurrentUserRole } from '../hooks/backendai';
88
import useControllableState_deprecated from '../hooks/useControllableState';
9-
import {
10-
isProjectAdminForId,
11-
useCurrentUserProjectRoles,
12-
} from '../hooks/useCurrentUserProjectRoles';
9+
import { useCurrentUserProjectRoles } from '../hooks/useCurrentUserProjectRoles';
1310
import { theme, Tooltip } from 'antd';
1411
import { BAIFlex, BAISelect, BAISelectProps } from 'backend.ai-ui';
1512
import * as _ from 'lodash-es';
@@ -132,7 +129,8 @@ const ProjectSelect: React.FC<ProjectSelectProps> = ({
132129
label: getLabel(key),
133130
title: key,
134131
options: _.map(_.sortBy(value, 'name'), (project) => {
135-
const showBadge = isProjectAdminForId(project?.id, projectAdminIds);
132+
const showBadge =
133+
!!project?.id && projectAdminIds.includes(project.id);
136134
return {
137135
label: showBadge ? (
138136
<BAIFlex gap={token.marginXS} align="center">

0 commit comments

Comments
 (0)