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 }) => {
const isMe = row.original.email === userInfo?.email;
if (isMe) {
return (
<Badge <Badge
variant={parseBooleanish(cell.getValue()) ? 'success' : 'secondary'} variant={
className="pl-[.5em]" parseBooleanish(cell.getValue()) ? 'success' : 'destructive'
}
> >
<LucideDot className="size-[1em] stroke-[8] mr-1" /> <LucideDot className="size-[1em] stroke-[8] mr-1" />
{t( {parseBooleanish(cell.getValue())
parseBooleanish(cell.getValue()) ? t('admin.active')
? 'admin.active' : t('admin.inactive')}
: 'admin.inactive',
)}
</Badge> </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,21 +347,68 @@ 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 }) => {
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"> <div className="opacity-0 group-hover/row:opacity-100 group-focus-within/row:opacity-100 transition-opacity">
<Button <Button
variant="transparent" variant="transparent"
size="icon" size="icon"
className="border-0" className="border-0"
onClick={() => onClick={() =>
navigate(`${Routes.AdminUserManagement}/${row.original.email}`) navigate(
`${Routes.AdminUserManagement}/${row.original.email}`,
)
} }
> >
<LucideClipboardList /> <LucideClipboardList />
</Button> </Button>
{!isMe && (
<>
<Button <Button
variant="transparent" variant="transparent"
size="icon" size="icon"
@ -344,11 +431,22 @@ function AdminUserManagement() {
> >
<LucideTrash2 /> <LucideTrash2 />
</Button> </Button>
</>
)}
</div> </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,23 +389,27 @@ 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.Admin,
Component: lazy(
() => import('@/pages/admin/layouts/authorized-layout'),
),
children: [
{ {
path: `${Routes.AdminUserManagement}/:id`, path: `${Routes.AdminUserManagement}/:id`,
Component: lazy(() => import('@/pages/admin/user-detail')), Component: lazy(() => import('@/pages/admin/user-detail')),
}, },
{ {
path: Routes.Admin,
Component: lazy( Component: lazy(
() => import('@/pages/admin/layouts/navigation-layout'), () => import('@/pages/admin/layouts/navigation-layout'),
), ),
wrappers: ['@/pages/admin/wrappers/authorized'],
children: [ children: [
{ {
path: Routes.AdminServices, path: Routes.AdminServices,
@ -434,8 +438,9 @@ const routeConfig = [
], ],
}, },
], ],
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);