diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 4093ebde1..5ed8912af 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -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', diff --git a/web/src/pages/admin/layouts/authorized-layout.tsx b/web/src/pages/admin/layouts/authorized-layout.tsx new file mode 100644 index 000000000..357928a1d --- /dev/null +++ b/web/src/pages/admin/layouts/authorized-layout.tsx @@ -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 ? : ; +} diff --git a/web/src/pages/admin/layouts/navigation-layout.tsx b/web/src/pages/admin/layouts/navigation-layout.tsx index 7986b62e9..76677a3eb 100644 --- a/web/src/pages/admin/layouts/navigation-layout.tsx +++ b/web/src/pages/admin/layouts/navigation-layout.tsx @@ -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, }); diff --git a/web/src/pages/admin/layouts/root-layout.tsx b/web/src/pages/admin/layouts/root-layout.tsx index 92fa40231..86a535285 100644 --- a/web/src/pages/admin/layouts/root-layout.tsx +++ b/web/src/pages/admin/layouts/root-layout.tsx @@ -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>] +>([getLocalStorageUserInfo(), () => {}]); + const AdminRootLayout = () => { - return ; + const userInfoCtx = useState(getLocalStorageUserInfo()); + + return ( + + + + ); }; export default AdminRootLayout; diff --git a/web/src/pages/admin/login.tsx b/web/src/pages/admin/login.tsx index 1b6031858..a6c2fe367 100644 --- a/web/src/pages/admin/login.tsx +++ b/web/src/pages/admin/login.tsx @@ -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'); diff --git a/web/src/pages/admin/users.tsx b/web/src/pages/admin/users.tsx index 860a39c41..4eafa54b0 100644 --- a/web/src/pages/admin/users.tsx +++ b/web/src/pages/admin/users.tsx @@ -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(); const globalFilterFn = createFuzzySearchFn([ @@ -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 }) => ( -
- {cell.getValue()} - - {row.original.is_superuser ? ( - {t('admin.superuser')} - ) : null} -
- ), }), ...(IS_ENTERPRISE @@ -267,37 +285,59 @@ function AdminUserManagement() { ] : []), - columnHelper.display({ - id: 'enable', - header: t('admin.enable'), - cell: ({ row }) => ( - { - updateUserStatusMutation.mutate({ - email: row.original.email, - isActive: checked, - }); - }} - disabled={updateUserStatusMutation.isPending} - /> - ), - }), columnHelper.accessor('is_active', { header: t('admin.status'), - cell: ({ cell }) => ( - - - {t( - parseBooleanish(cell.getValue()) - ? 'admin.active' - : 'admin.inactive', - )} - - ), + cell: ({ cell, row }) => { + const isMe = row.original.email === userInfo?.email; + + if (isMe) { + return ( + + + {parseBooleanish(cell.getValue()) + ? t('admin.active') + : t('admin.inactive')} + + ); + } + + return ( + + ); + }, 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 {t('admin.superuser')}; + } + + return ( + + ); + }, + }), + columnHelper.display({ id: 'actions', header: t('admin.actions'), - cell: ({ row }) => ( -
- - - -
- ), + cell: ({ row }) => { + const isMe = row.original.email === userInfo?.email; + + return ( +
+ + + {!isMe && ( + <> + + + + )} +
+ ); + }, }), ], - [t, updateUserRoleMutation, roleList, updateUserStatusMutation, navigate], + [ + t, + roleList, + updateUserRoleMutation, + userInfo?.email, + updateUserStatusMutation, + setSuperuserMutation, + navigate, + ], ); const table = useReactTable({ @@ -505,11 +603,11 @@ function AdminUserManagement() { - {() => } + {() => } - - + + diff --git a/web/src/pages/admin/wrappers/authorized.tsx b/web/src/pages/admin/wrappers/authorized.tsx deleted file mode 100644 index 97ea830b6..000000000 --- a/web/src/pages/admin/wrappers/authorized.tsx +++ /dev/null @@ -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 ? : ; -} diff --git a/web/src/routes.tsx b/web/src/routes.tsx index 834a6e026..f0c32db96 100644 --- a/web/src/routes.tsx +++ b/web/src/routes.tsx @@ -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: , 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: , - }, + } satisfies RouteObject, ]; const routers = createBrowserRouter(routeConfig, { diff --git a/web/src/services/admin-service.ts b/web/src/services/admin-service.ts index 6aec2b89c..3b19eddcd 100644 --- a/web/src/services/admin-service.ts +++ b/web/src/services/admin-service.ts @@ -157,6 +157,13 @@ export const createUser = (email: string, password: string) => username: email, password, }); + +export const grantSuperuser = (email: string) => + request.put>(api.adminSetSuperuser(email)); + +export const revokeSuperuser = (email: string) => + request.delete>(api.adminSetSuperuser(email)); + export const getUserDetails = (email: string) => request.get>( adminGetUserDetails(email), diff --git a/web/src/utils/api.ts b/web/src/utils/api.ts index 9091539b3..0fe695497 100644 --- a/web/src/utils/api.ts +++ b/web/src/utils/api.ts @@ -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) => diff --git a/web/src/utils/authorization-util.ts b/web/src/utils/authorization-util.ts index 227f33b54..e25e4915f 100644 --- a/web/src/utils/authorization-util.ts +++ b/web/src/utils/authorization-util.ts @@ -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);