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', role: 'Role',
user: 'User', user: 'User',
userType: 'User type',
superuser: 'Superuser', superuser: 'Superuser',
normalUser: 'Normal',
createTime: 'Create time', createTime: 'Create time',
lastLoginTime: 'Last login 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 { useTranslation } from 'react-i18next';
import { NavLink, Outlet, useNavigate } from 'react-router'; import { NavLink, Outlet, useNavigate } from 'react-router';
@ -21,10 +21,12 @@ import authorizationUtil from '@/utils/authorization-util';
import ThemeSwitch from '../components/theme-switch'; import ThemeSwitch from '../components/theme-switch';
import { IS_ENTERPRISE } from '../utils'; import { IS_ENTERPRISE } from '../utils';
import { CurrentUserInfoContext } from './root-layout';
const AdminNavigationLayout = () => { const AdminNavigationLayout = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [, setCurrentUserInfo] = useContext(CurrentUserInfoContext);
const { data: version } = useQuery({ const { data: version } = useQuery({
queryKey: ['admin/version'], queryKey: ['admin/version'],
@ -72,6 +74,10 @@ const AdminNavigationLayout = () => {
await logout(); await logout();
authorizationUtil.removeAll(); authorizationUtil.removeAll();
navigate(Routes.Admin); navigate(Routes.Admin);
setCurrentUserInfo({
userInfo: null,
source: null,
});
}, },
retry: false, retry: false,
}); });

View File

@ -1,7 +1,55 @@
import { createContext, Dispatch, SetStateAction, useState } from 'react';
import { Outlet } from 'react-router'; 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 = () => { const AdminRootLayout = () => {
return <Outlet />; const userInfoCtx = useState<CurrentUserInfo>(getLocalStorageUserInfo());
return (
<CurrentUserInfoContext.Provider value={userInfoCtx}>
<Outlet context={userInfoCtx} />
</CurrentUserInfoContext.Provider>
);
}; };
export default AdminRootLayout; export default AdminRootLayout;

View File

@ -1,5 +1,5 @@
import { type AxiosResponseHeaders } from 'axios'; import { type AxiosResponseHeaders } from 'axios';
import { useEffect, useId } from 'react'; import { useContext, useEffect, useId } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
@ -36,8 +36,11 @@ import { login } from '@/services/admin-service';
import { BgSvg } from '../login-next/bg'; import { BgSvg } from '../login-next/bg';
import ThemeSwitch from './components/theme-switch'; import ThemeSwitch from './components/theme-switch';
import { CurrentUserInfoContext } from './layouts/root-layout';
function AdminLogin() { function AdminLogin() {
const navigate = useNavigate(); const navigate = useNavigate();
const [, setCurrentUserInfo] = useContext(CurrentUserInfoContext);
const { t } = useTranslation('translation', { keyPrefix: 'login' }); const { t } = useTranslation('translation', { keyPrefix: 'login' });
const { isLogin } = useAuth(); const { isLogin } = useAuth();
@ -59,16 +62,19 @@ function AdminLogin() {
); );
const token = req.data.access_token; const token = req.data.access_token;
const userInfo = { // Lift to global user info context
avatar: req.data.avatar, setCurrentUserInfo({
name: req.data.nickname, userInfo: req.data,
email: req.data.email, source: 'serverRequest',
}; });
authorizationUtil.setItems({ authorizationUtil.setItems({
Authorization: authorization as string, Authorization: authorization as string,
Token: token, Token: token,
userInfo: JSON.stringify(userInfo), userInfo: JSON.stringify({
...req.data,
name: req.data.nickname,
}),
}); });
navigate('/admin/services'); 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 { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
@ -43,6 +43,7 @@ import {
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
@ -63,7 +64,6 @@ import {
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { RAGFlowPagination } from '@/components/ui/ragflow-pagination'; import { RAGFlowPagination } from '@/components/ui/ragflow-pagination';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { Switch } from '@/components/ui/switch';
import { import {
Table, Table,
TableBody, TableBody,
@ -81,8 +81,10 @@ import useCreateUserForm from './forms/user-form';
import { import {
createUser, createUser,
deleteUser, deleteUser,
grantSuperuser,
listRoles, listRoles,
listUsers, listUsers,
revokeSuperuser,
updateUserPassword, updateUserPassword,
updateUserRole, updateUserRole,
updateUserStatus, updateUserStatus,
@ -96,8 +98,15 @@ import {
parseBooleanish, parseBooleanish,
} from './utils'; } 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 EnterpriseFeature from './components/enterprise-feature';
import { CurrentUserInfoContext } from './layouts/root-layout';
const columnHelper = createColumnHelper<AdminService.ListUsersItem>(); const columnHelper = createColumnHelper<AdminService.ListUsersItem>();
const globalFilterFn = createFuzzySearchFn<AdminService.ListUsersItem>([ const globalFilterFn = createFuzzySearchFn<AdminService.ListUsersItem>([
@ -112,6 +121,8 @@ const STATUS_FILTER_OPTIONS = [
]; ];
function AdminUserManagement() { function AdminUserManagement() {
const [{ userInfo }] = useContext(CurrentUserInfoContext);
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -200,6 +211,22 @@ function AdminUserManagement() {
retry: false, 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 // Update user status mutation
const updateUserStatusMutation = useMutation({ const updateUserStatusMutation = useMutation({
mutationFn: (data: { email: string; isActive: boolean }) => mutationFn: (data: { email: string; isActive: boolean }) =>
@ -217,15 +244,6 @@ function AdminUserManagement() {
}), }),
columnHelper.accessor('nickname', { columnHelper.accessor('nickname', {
header: t('admin.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 ...(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', { columnHelper.accessor('is_active', {
header: t('admin.status'), header: t('admin.status'),
cell: ({ cell }) => ( cell: ({ cell, row }) => {
<Badge const isMe = row.original.email === userInfo?.email;
variant={parseBooleanish(cell.getValue()) ? 'success' : 'secondary'}
className="pl-[.5em]" if (isMe) {
> return (
<LucideDot className="size-[1em] stroke-[8] mr-1" /> <Badge
{t( variant={
parseBooleanish(cell.getValue()) parseBooleanish(cell.getValue()) ? 'success' : 'destructive'
? 'admin.active' }
: 'admin.inactive', >
)} <LucideDot className="size-[1em] stroke-[8] mr-1" />
</Badge> {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( filterFn: createColumnFilterFn(
(row, id, filterValue) => row.getValue(id) === filterValue, (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({ columnHelper.display({
id: 'actions', id: 'actions',
header: t('admin.actions'), header: t('admin.actions'),
cell: ({ row }) => ( cell: ({ row }) => {
<div className="opacity-0 group-hover/row:opacity-100 group-focus-within/row:opacity-100 transition-opacity"> const isMe = row.original.email === userInfo?.email;
<Button
variant="transparent" return (
size="icon" <div className="opacity-0 group-hover/row:opacity-100 group-focus-within/row:opacity-100 transition-opacity">
className="border-0" <Button
onClick={() => variant="transparent"
navigate(`${Routes.AdminUserManagement}/${row.original.email}`) size="icon"
} className="border-0"
> onClick={() =>
<LucideClipboardList /> navigate(
</Button> `${Routes.AdminUserManagement}/${row.original.email}`,
<Button )
variant="transparent" }
size="icon" >
className="border-0" <LucideClipboardList />
onClick={() => { </Button>
setUserToMakeAction(row.original);
setPasswordModalOpen(true); {!isMe && (
}} <>
> <Button
<LucideUserLock /> variant="transparent"
</Button> size="icon"
<Button className="border-0"
variant="danger" onClick={() => {
size="icon" setUserToMakeAction(row.original);
className="border-0" setPasswordModalOpen(true);
onClick={() => { }}
setUserToMakeAction(row.original); >
setDeleteModalOpen(true); <LucideUserLock />
}} </Button>
> <Button
<LucideTrash2 /> variant="danger"
</Button> size="icon"
</div> 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({ const table = useReactTable({
@ -505,11 +603,11 @@ function AdminUserManagement() {
<col className="w-[22%]" /> <col className="w-[22%]" />
<EnterpriseFeature> <EnterpriseFeature>
{() => <col className="w-[12%]" />} {() => <col className="w-24" />}
</EnterpriseFeature> </EnterpriseFeature>
<col className="w-[8%]" /> <col className="w-40" />
<col className="w-[15%]" /> <col className="w-40" />
<col className="w-52" /> <col className="w-52" />
</colgroup> </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 { lazy } from 'react';
import { createBrowserRouter, Navigate } from 'react-router'; import { createBrowserRouter, Navigate, type RouteObject } from 'react-router';
import FallbackComponent from './components/fallback-component'; import FallbackComponent from './components/fallback-component';
import { IS_ENTERPRISE } from './pages/admin/utils'; import { IS_ENTERPRISE } from './pages/admin/utils';
@ -389,53 +389,58 @@ const routeConfig = [
}, },
{ {
path: Routes.Admin, path: Routes.Admin,
layout: false,
Component: lazy(() => import('@/pages/admin/layouts/root-layout')), Component: lazy(() => import('@/pages/admin/layouts/root-layout')),
errorElement: <FallbackComponent />,
children: [ children: [
{ {
path: '', path: Routes.Admin,
Component: lazy(() => import('@/pages/admin/login')), Component: lazy(() => import('@/pages/admin/login')),
}, },
{
path: `${Routes.AdminUserManagement}/:id`,
Component: lazy(() => import('@/pages/admin/user-detail')),
},
{ {
path: Routes.Admin, path: Routes.Admin,
Component: lazy( Component: lazy(
() => import('@/pages/admin/layouts/navigation-layout'), () => import('@/pages/admin/layouts/authorized-layout'),
), ),
wrappers: ['@/pages/admin/wrappers/authorized'],
children: [ children: [
{ {
path: Routes.AdminServices, path: `${Routes.AdminUserManagement}/:id`,
Component: lazy(() => import('@/pages/admin/service-status')), Component: lazy(() => import('@/pages/admin/user-detail')),
}, },
{ {
path: Routes.AdminUserManagement, Component: lazy(
Component: lazy(() => import('@/pages/admin/users')), () => 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, { const routers = createBrowserRouter(routeConfig, {

View File

@ -157,6 +157,13 @@ export const createUser = (email: string, password: string) =>
username: email, username: email,
password, 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) => export const getUserDetails = (email: string) =>
request.get<ResponseData<[AdminService.UserDetail]>>( request.get<ResponseData<[AdminService.UserDetail]>>(
adminGetUserDetails(email), adminGetUserDetails(email),

View File

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

View File

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