feat: support admin assign superuser in admin ui (#12798)

### What problem does this PR solve?

Allow superuser(admin) to grant or revoke other superuser.

### Type of change

- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
Jimmy Ben Klieve
2026-01-23 18:08:46 +08:00
committed by GitHub
parent f3923452df
commit fa5284361c
11 changed files with 310 additions and 131 deletions

View File

@ -2451,7 +2451,9 @@ Important structured information may include: names, dates, locations, events, k
role: 'Role',
user: 'User',
userType: 'User type',
superuser: 'Superuser',
normalUser: 'Normal',
createTime: 'Create time',
lastLoginTime: 'Last login time',

View File

@ -0,0 +1,13 @@
import { useContext } from 'react';
import { Navigate, Outlet } from 'react-router';
import { Routes } from '@/routes';
import authorizationUtil from '@/utils/authorization-util';
import { CurrentUserInfoContext } from './root-layout';
export default function AdminAuthorizedLayout() {
const [{ userInfo }] = useContext(CurrentUserInfoContext);
const isLoggedIn = !!authorizationUtil.getAuthorization() && userInfo;
return isLoggedIn ? <Outlet /> : <Navigate to={Routes.Admin} />;
}

View File

@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useContext, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { NavLink, Outlet, useNavigate } from 'react-router';
@ -21,10 +21,12 @@ import authorizationUtil from '@/utils/authorization-util';
import ThemeSwitch from '../components/theme-switch';
import { IS_ENTERPRISE } from '../utils';
import { CurrentUserInfoContext } from './root-layout';
const AdminNavigationLayout = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [, setCurrentUserInfo] = useContext(CurrentUserInfoContext);
const { data: version } = useQuery({
queryKey: ['admin/version'],
@ -72,6 +74,10 @@ const AdminNavigationLayout = () => {
await logout();
authorizationUtil.removeAll();
navigate(Routes.Admin);
setCurrentUserInfo({
userInfo: null,
source: null,
});
},
retry: false,
});

View File

@ -1,7 +1,55 @@
import { createContext, Dispatch, SetStateAction, useState } from 'react';
import { Outlet } from 'react-router';
import type { IUserInfo } from '@/interfaces/database/user-setting';
import authorizationUtil from '@/utils/authorization-util';
type LocalStoragePersistedUserInfo = {
avatar: unknown;
name: string;
email: string;
};
export type CurrentUserInfo =
| {
userInfo: null;
source: null;
}
| {
userInfo: AdminService.LoginData | IUserInfo;
source: 'serverRequest';
}
| {
userInfo: LocalStoragePersistedUserInfo;
source: 'localStorage';
};
const getLocalStorageUserInfo = (): CurrentUserInfo => {
const userInfo = authorizationUtil.getUserInfoObject();
return userInfo
? {
userInfo: userInfo,
source: 'localStorage',
}
: {
userInfo: null,
source: null,
};
};
export const CurrentUserInfoContext = createContext<
[CurrentUserInfo, Dispatch<SetStateAction<CurrentUserInfo>>]
>([getLocalStorageUserInfo(), () => {}]);
const AdminRootLayout = () => {
return <Outlet />;
const userInfoCtx = useState<CurrentUserInfo>(getLocalStorageUserInfo());
return (
<CurrentUserInfoContext.Provider value={userInfoCtx}>
<Outlet context={userInfoCtx} />
</CurrentUserInfoContext.Provider>
);
};
export default AdminRootLayout;

View File

@ -1,5 +1,5 @@
import { type AxiosResponseHeaders } from 'axios';
import { useEffect, useId } from 'react';
import { useContext, useEffect, useId } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
@ -36,8 +36,11 @@ import { login } from '@/services/admin-service';
import { BgSvg } from '../login-next/bg';
import ThemeSwitch from './components/theme-switch';
import { CurrentUserInfoContext } from './layouts/root-layout';
function AdminLogin() {
const navigate = useNavigate();
const [, setCurrentUserInfo] = useContext(CurrentUserInfoContext);
const { t } = useTranslation('translation', { keyPrefix: 'login' });
const { isLogin } = useAuth();
@ -59,16 +62,19 @@ function AdminLogin() {
);
const token = req.data.access_token;
const userInfo = {
avatar: req.data.avatar,
name: req.data.nickname,
email: req.data.email,
};
// Lift to global user info context
setCurrentUserInfo({
userInfo: req.data,
source: 'serverRequest',
});
authorizationUtil.setItems({
Authorization: authorization as string,
Token: token,
userInfo: JSON.stringify(userInfo),
userInfo: JSON.stringify({
...req.data,
name: req.data.nickname,
}),
});
navigate('/admin/services');

View File

@ -1,4 +1,4 @@
import { useLayoutEffect, useMemo, useState } from 'react';
import { useContext, useLayoutEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
@ -43,6 +43,7 @@ import {
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
@ -63,7 +64,6 @@ import {
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { RAGFlowPagination } from '@/components/ui/ragflow-pagination';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Switch } from '@/components/ui/switch';
import {
Table,
TableBody,
@ -81,8 +81,10 @@ import useCreateUserForm from './forms/user-form';
import {
createUser,
deleteUser,
grantSuperuser,
listRoles,
listUsers,
revokeSuperuser,
updateUserPassword,
updateUserRole,
updateUserStatus,
@ -96,8 +98,15 @@ import {
parseBooleanish,
} from './utils';
import { DialogDescription } from '@radix-ui/react-dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import EnterpriseFeature from './components/enterprise-feature';
import { CurrentUserInfoContext } from './layouts/root-layout';
const columnHelper = createColumnHelper<AdminService.ListUsersItem>();
const globalFilterFn = createFuzzySearchFn<AdminService.ListUsersItem>([
@ -112,6 +121,8 @@ const STATUS_FILTER_OPTIONS = [
];
function AdminUserManagement() {
const [{ userInfo }] = useContext(CurrentUserInfoContext);
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
@ -200,6 +211,22 @@ function AdminUserManagement() {
retry: false,
});
const setSuperuserMutation = useMutation({
mutationFn: ({
email,
type,
}: {
email: string;
type: 'grant' | 'revoke';
}) => {
return type === 'grant' ? grantSuperuser(email) : revokeSuperuser(email);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin/listUsers'] });
},
retry: false,
});
// Update user status mutation
const updateUserStatusMutation = useMutation({
mutationFn: (data: { email: string; isActive: boolean }) =>
@ -217,15 +244,6 @@ function AdminUserManagement() {
}),
columnHelper.accessor('nickname', {
header: t('admin.nickname'),
cell: ({ row, cell }) => (
<div className="flex items-center">
<span className="mr-2 empty:hidden">{cell.getValue()}</span>
{row.original.is_superuser ? (
<Badge variant="secondary">{t('admin.superuser')}</Badge>
) : null}
</div>
),
}),
...(IS_ENTERPRISE
@ -267,37 +285,59 @@ function AdminUserManagement() {
]
: []),
columnHelper.display({
id: 'enable',
header: t('admin.enable'),
cell: ({ row }) => (
<Switch
checked={parseBooleanish(row.original.is_active)}
onCheckedChange={(checked) => {
updateUserStatusMutation.mutate({
email: row.original.email,
isActive: checked,
});
}}
disabled={updateUserStatusMutation.isPending}
/>
),
}),
columnHelper.accessor('is_active', {
header: t('admin.status'),
cell: ({ cell }) => (
<Badge
variant={parseBooleanish(cell.getValue()) ? 'success' : 'secondary'}
className="pl-[.5em]"
>
<LucideDot className="size-[1em] stroke-[8] mr-1" />
{t(
parseBooleanish(cell.getValue())
? 'admin.active'
: 'admin.inactive',
)}
</Badge>
),
cell: ({ cell, row }) => {
const isMe = row.original.email === userInfo?.email;
if (isMe) {
return (
<Badge
variant={
parseBooleanish(cell.getValue()) ? 'success' : 'destructive'
}
>
<LucideDot className="size-[1em] stroke-[8] mr-1" />
{parseBooleanish(cell.getValue())
? t('admin.active')
: t('admin.inactive')}
</Badge>
);
}
return (
<Select
disabled={updateUserStatusMutation.isPending}
value={cell.getValue()}
onValueChange={(value) =>
updateUserStatusMutation.mutate({
email: row.original.email,
isActive: parseBooleanish(value),
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">
<div className="flex items-center">
<LucideDot className="size-[1em] stroke-[8] mr-1" />
{t('admin.inactive')}
</div>
</SelectItem>
<SelectItem value="1">
<div className="flex items-center text-state-success">
<LucideDot className="size-[1em] stroke-[8] mr-1" />
{t('admin.active')}
</div>
</SelectItem>
</SelectContent>
</Select>
);
},
filterFn: createColumnFilterFn(
(row, id, filterValue) => row.getValue(id) === filterValue,
{
@ -307,48 +347,106 @@ function AdminUserManagement() {
},
),
}),
columnHelper.accessor('is_superuser', {
header: t('admin.userType'),
cell: ({ cell, row }) => {
const isMe = row.original.email === userInfo?.email;
if (isMe) {
return <Badge variant="secondary">{t('admin.superuser')}</Badge>;
}
return (
<Select
disabled={
setSuperuserMutation.isPending ||
row.original.email === userInfo?.email
}
value={cell.getValue() ? 'superuser' : 'normal'}
onValueChange={(value) => {
setSuperuserMutation.mutate({
email: row.original.email,
type: value === 'superuser' ? 'grant' : 'revoke',
});
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="normal">{t('admin.normalUser')}</SelectItem>
<SelectItem value="superuser">
{t('admin.superuser')}
</SelectItem>
</SelectContent>
</Select>
);
},
}),
columnHelper.display({
id: 'actions',
header: t('admin.actions'),
cell: ({ row }) => (
<div className="opacity-0 group-hover/row:opacity-100 group-focus-within/row:opacity-100 transition-opacity">
<Button
variant="transparent"
size="icon"
className="border-0"
onClick={() =>
navigate(`${Routes.AdminUserManagement}/${row.original.email}`)
}
>
<LucideClipboardList />
</Button>
<Button
variant="transparent"
size="icon"
className="border-0"
onClick={() => {
setUserToMakeAction(row.original);
setPasswordModalOpen(true);
}}
>
<LucideUserLock />
</Button>
<Button
variant="danger"
size="icon"
className="border-0"
onClick={() => {
setUserToMakeAction(row.original);
setDeleteModalOpen(true);
}}
>
<LucideTrash2 />
</Button>
</div>
),
cell: ({ row }) => {
const isMe = row.original.email === userInfo?.email;
return (
<div className="opacity-0 group-hover/row:opacity-100 group-focus-within/row:opacity-100 transition-opacity">
<Button
variant="transparent"
size="icon"
className="border-0"
onClick={() =>
navigate(
`${Routes.AdminUserManagement}/${row.original.email}`,
)
}
>
<LucideClipboardList />
</Button>
{!isMe && (
<>
<Button
variant="transparent"
size="icon"
className="border-0"
onClick={() => {
setUserToMakeAction(row.original);
setPasswordModalOpen(true);
}}
>
<LucideUserLock />
</Button>
<Button
variant="danger"
size="icon"
className="border-0"
onClick={() => {
setUserToMakeAction(row.original);
setDeleteModalOpen(true);
}}
>
<LucideTrash2 />
</Button>
</>
)}
</div>
);
},
}),
],
[t, updateUserRoleMutation, roleList, updateUserStatusMutation, navigate],
[
t,
roleList,
updateUserRoleMutation,
userInfo?.email,
updateUserStatusMutation,
setSuperuserMutation,
navigate,
],
);
const table = useReactTable({
@ -505,11 +603,11 @@ function AdminUserManagement() {
<col className="w-[22%]" />
<EnterpriseFeature>
{() => <col className="w-[12%]" />}
{() => <col className="w-24" />}
</EnterpriseFeature>
<col className="w-[8%]" />
<col className="w-[15%]" />
<col className="w-40" />
<col className="w-40" />
<col className="w-52" />
</colgroup>

View File

@ -1,9 +0,0 @@
import { Routes } from '@/routes';
import authorizationUtil from '@/utils/authorization-util';
import { Navigate, Outlet } from 'react-router';
export default function AuthorizedAdminWrapper() {
const isLogin = !!authorizationUtil.getAuthorization();
return isLogin ? <Outlet /> : <Navigate to={Routes.Admin} />;
}

View File

@ -1,5 +1,5 @@
import { lazy } from 'react';
import { createBrowserRouter, Navigate } from 'react-router';
import { createBrowserRouter, Navigate, type RouteObject } from 'react-router';
import FallbackComponent from './components/fallback-component';
import { IS_ENTERPRISE } from './pages/admin/utils';
@ -389,53 +389,58 @@ const routeConfig = [
},
{
path: Routes.Admin,
layout: false,
Component: lazy(() => import('@/pages/admin/layouts/root-layout')),
errorElement: <FallbackComponent />,
children: [
{
path: '',
path: Routes.Admin,
Component: lazy(() => import('@/pages/admin/login')),
},
{
path: `${Routes.AdminUserManagement}/:id`,
Component: lazy(() => import('@/pages/admin/user-detail')),
},
{
path: Routes.Admin,
Component: lazy(
() => import('@/pages/admin/layouts/navigation-layout'),
() => import('@/pages/admin/layouts/authorized-layout'),
),
wrappers: ['@/pages/admin/wrappers/authorized'],
children: [
{
path: Routes.AdminServices,
Component: lazy(() => import('@/pages/admin/service-status')),
path: `${Routes.AdminUserManagement}/:id`,
Component: lazy(() => import('@/pages/admin/user-detail')),
},
{
path: Routes.AdminUserManagement,
Component: lazy(() => import('@/pages/admin/users')),
Component: lazy(
() => import('@/pages/admin/layouts/navigation-layout'),
),
children: [
{
path: Routes.AdminServices,
Component: lazy(() => import('@/pages/admin/service-status')),
},
{
path: Routes.AdminUserManagement,
Component: lazy(() => import('@/pages/admin/users')),
},
...(IS_ENTERPRISE
? [
{
path: Routes.AdminWhitelist,
Component: lazy(() => import('@/pages/admin/whitelist')),
},
{
path: Routes.AdminRoles,
Component: lazy(() => import('@/pages/admin/roles')),
},
{
path: Routes.AdminMonitoring,
Component: lazy(() => import('@/pages/admin/monitoring')),
},
]
: []),
],
},
...(IS_ENTERPRISE
? [
{
path: Routes.AdminWhitelist,
Component: lazy(() => import('@/pages/admin/whitelist')),
},
{
path: Routes.AdminRoles,
Component: lazy(() => import('@/pages/admin/roles')),
},
{
path: Routes.AdminMonitoring,
Component: lazy(() => import('@/pages/admin/monitoring')),
},
]
: []),
],
},
],
errorElement: <FallbackComponent />,
},
} satisfies RouteObject,
];
const routers = createBrowserRouter(routeConfig, {

View File

@ -157,6 +157,13 @@ export const createUser = (email: string, password: string) =>
username: email,
password,
});
export const grantSuperuser = (email: string) =>
request.put<ResponseData<void>>(api.adminSetSuperuser(email));
export const revokeSuperuser = (email: string) =>
request.delete<ResponseData<void>>(api.adminSetSuperuser(email));
export const getUserDetails = (email: string) =>
request.get<ResponseData<[AdminService.UserDetail]>>(
adminGetUserDetails(email),

View File

@ -263,6 +263,8 @@ export default {
adminLogout: `${ExternalApi}${api_host}/admin/logout`,
adminListUsers: `${ExternalApi}${api_host}/admin/users`,
adminCreateUser: `${ExternalApi}${api_host}/admin/users`,
adminSetSuperuser: (username: string) =>
`${ExternalApi}${api_host}/admin/users/${username}/admin`,
adminGetUserDetails: (username: string) =>
`${ExternalApi}${api_host}/admin/users/${username}`,
adminUpdateUserStatus: (username: string) =>

View File

@ -13,7 +13,8 @@ const storage = {
return localStorage.getItem(UserInfo);
},
getUserInfoObject: () => {
return JSON.parse(localStorage.getItem('userInfo') || '');
const userInfoStr = localStorage.getItem(UserInfo);
return userInfoStr ? JSON.parse(userInfoStr) : null;
},
setAuthorization: (value: string) => {
localStorage.setItem(Authorization, value);