diff --git a/web/.umirc.ts b/web/.umirc.ts
index 7cf7f16b8..044ea3036 100644
--- a/web/.umirc.ts
+++ b/web/.umirc.ts
@@ -38,6 +38,13 @@ export default defineConfig({
{ from: 'node_modules/monaco-editor/min/vs/', to: 'dist/vs/' },
],
proxy: [
+ {
+ context: ['/api/v1/admin'],
+ target: 'http://127.0.0.1:9381/',
+ changeOrigin: true,
+ ws: true,
+ logger: console,
+ },
{
context: ['/api', '/v1'],
target: 'http://127.0.0.1:9380/',
diff --git a/web/src/components/ui/scroll-area.tsx b/web/src/components/ui/scroll-area.tsx
index 141463d83..e8f989ee2 100644
--- a/web/src/components/ui/scroll-area.tsx
+++ b/web/src/components/ui/scroll-area.tsx
@@ -39,7 +39,8 @@ const ScrollArea = React.forwardRef<
{children}
-
+
+
));
diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts
index 88c17b1e1..718a867be 100644
--- a/web/src/locales/en.ts
+++ b/web/src/locales/en.ts
@@ -278,8 +278,8 @@ export default {
tocExtractionTip:
" For existing chunks, generate a hierarchical table of contents (one directory per file). During queries, when Directory Enhancement is activated, the system will use a large model to determine which directory items are relevant to the user's question, thereby identifying the relevant chunks.",
deleteGenerateModalContent: `
-
Deleting the generated {{type}} results
- will remove all derived entities and relationships from this dataset.
+
Deleting the generated {{type}} results
+ will remove all derived entities and relationships from this dataset.
Your original files will remain intact.
Do you want to continue?
@@ -1813,15 +1813,15 @@ Important structured information may include: names, dates, locations, events, k
`,
changeStepModalTitle: 'Step Switch Warning',
changeStepModalContent: `
-
You are currently editing the results of this stage.
- If you switch to a later stage, your changes will be lost.
+ You are currently editing the results of this stage.
+ If you switch to a later stage, your changes will be lost.
To keep them, please click Rerun to re-run the current stage.
`,
changeStepModalConfirmText: 'Switch Anyway',
changeStepModalCancelText: 'Cancel',
unlinkPipelineModalTitle: 'Unlink Ingestion pipeline',
unlinkPipelineModalContent: `
- Once unlinked, this Dataset will no longer be connected to the current Ingestion pipeline.
- Files that are already being parsed will continue until completion
+ Once unlinked, this Dataset will no longer be connected to the current Ingestion pipeline.
+ Files that are already being parsed will continue until completion
Files that are not yet parsed will no longer be processed
Are you sure you want to proceed?
`,
unlinkPipelineModalConfirmText: 'Unlink',
@@ -1837,5 +1837,125 @@ Important structured information may include: names, dates, locations, events, k
processingFailedTip: 'Total failed processes',
processing: 'Processing',
},
+ admin: {
+ loginTitle: 'RAGFlow ADMIN',
+ title: 'RAGFlow admin',
+ confirm: 'Confirm',
+ close: 'Close',
+ yes: 'Yes',
+ no: 'No',
+ delete: 'Delete',
+ cancel: 'Cancel',
+ reset: 'Reset',
+ import: 'Import',
+ description: 'Description',
+ noDescription: 'No description',
+
+ resourceType: {
+ dataset: 'Dataset',
+ chat: 'Chat',
+ agent: 'Agent',
+ search: 'Search',
+ file: 'File',
+ team: 'Team',
+ memory: 'Memory',
+ },
+
+ permissionType: {
+ enable: 'Enable',
+ read: 'Read',
+ write: 'Write',
+ share: 'Share',
+ },
+
+ serviceStatus: 'Service status',
+ userManagement: 'User management',
+ registrationWhitelist: 'Registration whitelist',
+ roles: 'Roles',
+ monitoring: 'Monitoring',
+
+ active: 'Active',
+ inactive: 'Inactive',
+ enable: 'Enable',
+ disable: 'Disable',
+ all: 'All',
+ actions: 'Actions',
+ newUser: 'New User',
+ email: 'Email',
+ name: 'Name',
+ nickname: 'Nickname',
+ status: 'Status',
+ id: 'ID',
+ serviceType: 'Service type',
+ host: 'Host',
+ port: 'Port',
+
+ role: 'Role',
+ user: 'User',
+ superuser: 'Superuser',
+
+ createTime: 'Create time',
+ lastLoginTime: 'Last login time',
+ lastUpdateTime: 'Last update time',
+
+ isAnonymous: 'Is Anonymous',
+
+ deleteUser: 'Delete user',
+ deleteUserConfirmation: 'Are you sure you want to delete this user?',
+
+ createNewUser: 'Create new user',
+ changePassword: 'Change password',
+ newPassword: 'New password',
+ confirmNewPassword: 'Confirm new password',
+ password: 'Password',
+ confirmPassword: 'Confirm password',
+
+ invalidEmail: 'Please input a valid email address!',
+ passwordRequired: 'Please input your password!',
+ passwordMinLength: 'Password must be more than 8 characters.',
+ confirmPasswordRequired: 'Please confirm your password!',
+ confirmPasswordDoNotMatch: 'The password that you entered do not match!',
+
+ read: 'Read',
+ write: 'Write',
+ share: 'Share',
+ create: 'Create',
+
+ extraInfo: 'Extra information',
+ serviceDetail: `Service {{name}} detail`,
+
+ whitelistManagement: 'Whitelist management',
+ exportAsExcel: 'Export Excel',
+ importFromExcel: 'Import Excel',
+ createEmail: 'Create email',
+ deleteEmail: 'Delete email',
+ editEmail: 'Edit email',
+ deleteWhitelistEmailConfirmation:
+ 'Are you sure you want to delete this email from whitelist? This action cannot be undone.',
+
+ importWhitelist: 'Import whitelist (excel)',
+ importSelectExcelFile: 'Excel file (.xlsx)',
+ importOverwriteExistingEmails: 'Overwrite existing emails',
+ importInvalidExcelFile: 'Please select a valid Excel file',
+ importFileRequired: 'Please select a file to import',
+ importFileTips:
+ 'File must contain a single header column named email.',
+
+ chunkNum: 'Chunks',
+ docNum: 'Documents',
+ tokenNum: 'Tokens used',
+ language: 'Language',
+ createDate: 'Create date',
+ updateDate: 'Update date',
+ permission: 'Permission',
+
+ agentTitle: 'Agent title',
+ canvasCategory: 'Canvas category',
+
+ newRole: 'New Role',
+ addNewRole: 'Add new role',
+ roleName: 'Role name',
+ resources: 'Resources',
+ },
},
};
diff --git a/web/src/pages/404.jsx b/web/src/pages/404.jsx
index db9ee69c8..7ca998ed7 100644
--- a/web/src/pages/404.jsx
+++ b/web/src/pages/404.jsx
@@ -1,14 +1,24 @@
+import { Routes } from '@/routes';
import { Button, Result } from 'antd';
-import { history } from 'umi';
+import { history, useLocation } from 'umi';
const NoFoundPage = () => {
+ const location = useLocation();
+
return (
history.push('/')}>
+ {
+ history.push(
+ location.pathname.startsWith(Routes.Admin) ? Routes.Admin : '/',
+ );
+ }}
+ >
Business
}
diff --git a/web/src/pages/admin/components/enterprise-feature.tsx b/web/src/pages/admin/components/enterprise-feature.tsx
new file mode 100644
index 000000000..6b92d2281
--- /dev/null
+++ b/web/src/pages/admin/components/enterprise-feature.tsx
@@ -0,0 +1,13 @@
+import { IS_ENTERPRISE } from '../utils';
+
+export default function EnterpriseFeature({
+ children,
+}: {
+ children: () => React.ReactNode;
+}) {
+ return IS_ENTERPRISE
+ ? typeof children === 'function'
+ ? children()
+ : children
+ : null;
+}
diff --git a/web/src/pages/admin/components/theme-switch.tsx b/web/src/pages/admin/components/theme-switch.tsx
new file mode 100644
index 000000000..6ba97e3f9
--- /dev/null
+++ b/web/src/pages/admin/components/theme-switch.tsx
@@ -0,0 +1,47 @@
+import { useIsDarkTheme, useTheme } from '@/components/theme-provider';
+import { ThemeEnum } from '@/constants/common';
+import { cn } from '@/lib/utils';
+import { Root, Thumb } from '@radix-ui/react-switch';
+import { LucideMoon, LucideSun } from 'lucide-react';
+import { forwardRef } from 'react';
+
+const ThemeSwitch = forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => {
+ const { setTheme } = useTheme();
+ const isDark = useIsDarkTheme();
+
+ return (
+
+ setTheme(value ? ThemeEnum.Dark : ThemeEnum.Light)
+ }
+ >
+
+
+
+
+ );
+});
+
+export default ThemeSwitch;
diff --git a/web/src/pages/admin/forms/change-password-form.tsx b/web/src/pages/admin/forms/change-password-form.tsx
new file mode 100644
index 000000000..a0627c916
--- /dev/null
+++ b/web/src/pages/admin/forms/change-password-form.tsx
@@ -0,0 +1,150 @@
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form';
+import { Input } from '@/components/ui/input';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useCallback, useId, useMemo } from 'react';
+import { useForm } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+import { z } from 'zod';
+
+interface ChangePasswordFormData {
+ newPassword: string;
+ confirmPassword: string;
+}
+
+interface ChangePasswordFormProps {
+ id: string;
+ form: ReturnType>;
+ email?: string;
+ onSubmit?: (data: ChangePasswordFormData) => void;
+}
+
+export const ChangePasswordForm = ({
+ id,
+ form,
+ email,
+ onSubmit = () => {},
+}: ChangePasswordFormProps) => {
+ const { t } = useTranslation();
+
+ return (
+
+
+ );
+};
+
+// Export the form validation state for parent component
+function useChangePasswordForm() {
+ const { t } = useTranslation();
+ const id = useId();
+
+ const schema = useMemo(() => {
+ return z
+ .object({
+ newPassword: z
+ .string()
+ .min(8, { message: t('admin.passwordMinLength') }),
+ confirmPassword: z
+ .string()
+ .min(8, { message: t('admin.confirmPasswordRequired') }),
+ })
+ .refine((data) => data.newPassword === data.confirmPassword, {
+ message: t('admin.confirmPasswordDoNotMatch'),
+ path: ['confirmPassword'],
+ });
+ }, [t]);
+
+ const form = useForm({
+ defaultValues: {
+ newPassword: '',
+ confirmPassword: '',
+ },
+ resolver: zodResolver(schema),
+ });
+
+ const FormComponent = useCallback(
+ (props: Partial) => (
+
+ ),
+ [id, form],
+ );
+
+ return {
+ schema,
+ id,
+ form,
+ FormComponent,
+ };
+}
+
+export default useChangePasswordForm;
diff --git a/web/src/pages/admin/forms/email-form.tsx b/web/src/pages/admin/forms/email-form.tsx
new file mode 100644
index 000000000..e5fc2636a
--- /dev/null
+++ b/web/src/pages/admin/forms/email-form.tsx
@@ -0,0 +1,102 @@
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form';
+import { Input } from '@/components/ui/input';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useCallback, useId, useMemo } from 'react';
+import { useForm } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+import { z } from 'zod';
+
+interface CreateEmailFormData {
+ email: string;
+}
+
+interface CreateEmailFormProps {
+ id: string;
+ form: ReturnType>;
+ onSubmit?: (data: CreateEmailFormData) => void;
+}
+
+export const CreateEmailForm = ({
+ id,
+ form,
+ onSubmit = () => {},
+}: CreateEmailFormProps) => {
+ const { t } = useTranslation();
+
+ return (
+
+
+ );
+};
+
+// Export the form validation state for parent component
+function useCreateEmailForm(props?: {
+ defaultValues: Partial;
+}) {
+ const { t } = useTranslation();
+ const id = useId();
+
+ const schema = useMemo(() => {
+ return z.object({
+ email: z.string().email({ message: t('admin.invalidEmail') }),
+ });
+ }, [t]);
+
+ const form = useForm({
+ defaultValues: {
+ email: '',
+ ...(props?.defaultValues ?? {}),
+ },
+ resolver: zodResolver(schema),
+ });
+
+ const FormComponent = useCallback(
+ (props: Partial) => (
+
+ ),
+ [id, form],
+ );
+
+ return {
+ schema,
+ id,
+ form,
+ FormComponent,
+ };
+}
+
+export default useCreateEmailForm;
diff --git a/web/src/pages/admin/forms/import-excel-form.tsx b/web/src/pages/admin/forms/import-excel-form.tsx
new file mode 100644
index 000000000..67c1b9033
--- /dev/null
+++ b/web/src/pages/admin/forms/import-excel-form.tsx
@@ -0,0 +1,155 @@
+import { Checkbox } from '@/components/ui/checkbox';
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form';
+import { Input } from '@/components/ui/input';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useCallback, useId, useMemo } from 'react';
+import { useForm } from 'react-hook-form';
+import { Trans, useTranslation } from 'react-i18next';
+import { z } from 'zod';
+
+interface ImportExcelFormData {
+ file: FileList;
+ overwriteExisting: boolean;
+}
+
+interface ImportExcelFormProps {
+ id: string;
+ form: ReturnType>;
+ onSubmit?: (data: ImportExcelFormData) => void;
+}
+
+export const ImportExcelForm = ({
+ id,
+ form,
+ onSubmit = () => {},
+}: ImportExcelFormProps) => {
+ const { t } = useTranslation();
+
+ return (
+
+
+ );
+};
+
+// Export the form validation state for parent component
+function useImportExcelForm() {
+ const { t } = useTranslation();
+ const id = useId();
+
+ const schema = useMemo(() => {
+ return z.object({
+ file: z
+ .any()
+ .refine((files) => files && files.length > 0, {
+ message: t('admin.importFileRequired'),
+ })
+ .refine(
+ (files) => {
+ if (!files || files.length === 0) return false;
+ const [file] = files;
+ return (
+ file.type ===
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
+ // || file.type === 'application/vnd.ms-excel'
+ file.name.endsWith('.xlsx')
+ );
+ // || file.name.endsWith('.xls');
+ },
+ {
+ message: t('admin.invalidExcelFile'),
+ },
+ ),
+ overwriteExisting: z.boolean().optional(),
+ });
+ }, [t]);
+
+ const form = useForm({
+ defaultValues: {
+ file: undefined,
+ overwriteExisting: false,
+ },
+ resolver: zodResolver(schema),
+ });
+
+ const FormComponent = useCallback(
+ (props: Partial) => (
+
+ ),
+ [id, form],
+ );
+
+ return {
+ schema,
+ id,
+ form,
+ FormComponent,
+ };
+}
+
+export default useImportExcelForm;
diff --git a/web/src/pages/admin/forms/role-form.tsx b/web/src/pages/admin/forms/role-form.tsx
new file mode 100644
index 000000000..dd236bc31
--- /dev/null
+++ b/web/src/pages/admin/forms/role-form.tsx
@@ -0,0 +1,207 @@
+import { Card, CardContent } from '@/components/ui/card';
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Switch } from '@/components/ui/switch';
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from '@/components/ui/tabs-underlined';
+import { AdminService, listResources } from '@/services/admin-service';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useQuery } from '@tanstack/react-query';
+import { useCallback, useId, useMemo } from 'react';
+import { useForm } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+import { z } from 'zod';
+
+interface CreateRoleFormData {
+ name: string;
+ description: string;
+ permissions: Record;
+}
+
+interface CreateRoleFormProps {
+ id: string;
+ form: ReturnType>;
+ onSubmit?: (data: CreateRoleFormData) => void;
+}
+
+const PERMISSION_TYPES = ['enable', 'read', 'write', 'share'] as const;
+
+export const CreateRoleForm = ({
+ id,
+ form,
+ onSubmit = () => {},
+}: CreateRoleFormProps) => {
+ const { t } = useTranslation();
+
+ const { data: resourceTypes } = useQuery({
+ queryKey: ['admin/resourceTypes'],
+ queryFn: async () => (await listResources()).data.data.resource_types,
+ });
+
+ return (
+
+
+ );
+};
+
+// Export the form validation state for parent component
+function useCreateRoleForm(props?: {
+ defaultValues: Partial;
+}) {
+ const { t } = useTranslation();
+ const id = useId();
+
+ const schema = useMemo(() => {
+ return z.object({
+ name: z.string().min(1, { message: 'Role name is required' }),
+ description: z.string().optional(),
+ permissions: z.record(
+ z.string(),
+ z.object({
+ enable: z.boolean(),
+ read: z.boolean(),
+ write: z.boolean(),
+ share: z.boolean(),
+ }),
+ ),
+ });
+ }, [t]);
+
+ const form = useForm({
+ defaultValues: {
+ name: '',
+ description: '',
+ permissions: {},
+ ...(props?.defaultValues ?? {}),
+ },
+ resolver: zodResolver(schema),
+ });
+
+ const FormComponent = useCallback(
+ (props: Partial) => (
+
+ ),
+ [form],
+ );
+
+ return {
+ schema,
+ id,
+ form,
+ FormComponent,
+ };
+}
+
+export default useCreateRoleForm;
diff --git a/web/src/pages/admin/forms/user-form.tsx b/web/src/pages/admin/forms/user-form.tsx
new file mode 100644
index 000000000..2ffe16a26
--- /dev/null
+++ b/web/src/pages/admin/forms/user-form.tsx
@@ -0,0 +1,215 @@
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useQuery } from '@tanstack/react-query';
+import { useCallback, useId, useMemo } from 'react';
+import { useForm } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+import { z } from 'zod';
+
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form';
+import { Input } from '@/components/ui/input';
+
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { listRoles } from '@/services/admin-service';
+import EnterpriseFeature from '../components/enterprise-feature';
+
+interface CreateUserFormData {
+ email: string;
+ password: string;
+ confirmPassword: string;
+ role?: string;
+}
+
+interface CreateUserFormProps {
+ id: string;
+ form: ReturnType>;
+ onSubmit?: (data: CreateUserFormData) => void;
+}
+
+export const CreateUserForm = ({
+ id,
+ form,
+ onSubmit = () => {},
+}: CreateUserFormProps) => {
+ const { t } = useTranslation();
+
+ const { data: roleList } = useQuery({
+ queryKey: ['admin/listRoles'],
+ queryFn: async () => (await listRoles()).data.data.roles,
+ });
+
+ return (
+
+
+ );
+};
+
+// Export the form validation state for parent component
+function useCreateUserForm(props?: {
+ defaultValues: Partial;
+}) {
+ const { t } = useTranslation();
+ const id = useId();
+
+ const schema = useMemo(() => {
+ return z
+ .object({
+ email: z.string().email({ message: t('admin.invalidEmail') }),
+ password: z.string().min(6, { message: t('admin.passwordMinLength') }),
+ confirmPassword: z
+ .string()
+ .min(1, { message: t('admin.confirmPasswordRequired') }),
+ role: z.string().optional(),
+ })
+ .refine((data) => data.password === data.confirmPassword, {
+ message: t('admin.confirmPasswordDoNotMatch'),
+ path: ['confirmPassword'],
+ });
+ }, [t]);
+
+ const form = useForm({
+ defaultValues: {
+ email: '',
+ password: '',
+ confirmPassword: '',
+ ...(props?.defaultValues ?? {}),
+ },
+ resolver: zodResolver(schema),
+ });
+
+ const FormComponent = useCallback(
+ (props: Partial) => (
+
+ ),
+ [id, form],
+ );
+
+ return {
+ schema,
+ id,
+ form,
+ FormComponent,
+ };
+}
+
+export default useCreateUserForm;
diff --git a/web/src/pages/admin/index.tsx b/web/src/pages/admin/index.tsx
new file mode 100644
index 000000000..9009de635
--- /dev/null
+++ b/web/src/pages/admin/index.tsx
@@ -0,0 +1,265 @@
+import Spotlight from '@/components/spotlight';
+import { ButtonLoading } from '@/components/ui/button';
+import { Card, CardContent, CardFooter } from '@/components/ui/card';
+import { Checkbox } from '@/components/ui/checkbox';
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form';
+import { Input } from '@/components/ui/input';
+import { Authorization } from '@/constants/authorization';
+import { useAuth } from '@/hooks/auth-hooks';
+import { cn } from '@/lib/utils';
+import { Routes } from '@/routes';
+import adminService from '@/services/admin-service';
+import { rsaPsw } from '@/utils';
+import authorizationUtil from '@/utils/authorization-util';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useMutation } from '@tanstack/react-query';
+import { AxiosResponseHeaders } from 'axios';
+import { LucideEye, LucideEyeOff } from 'lucide-react';
+import { useEffect, useId, useState } from 'react';
+import { SubmitHandler, useForm } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+import { useNavigate } from 'umi';
+import { z } from 'zod';
+import { BgSvg } from '../login-next/bg';
+import ThemeSwitch from './components/theme-switch';
+
+function AdminLogin() {
+ const navigate = useNavigate();
+ const { t } = useTranslation('translation', { keyPrefix: 'login' });
+ const { isLogin } = useAuth();
+
+ const [showPassword, setShowPassword] = useState(false);
+
+ const { isPending: signLoading, mutateAsync: login } = useMutation({
+ mutationKey: ['adminLogin'],
+ mutationFn: async (params: { email: string; password: string }) => {
+ const request = await adminService.login(params);
+ const { data: req, headers } = request;
+
+ if (req.code === 0) {
+ const authorization = (headers as AxiosResponseHeaders)?.get(
+ Authorization,
+ );
+ const token = req.data.access_token;
+
+ const userInfo = {
+ avatar: req.data.avatar,
+ name: req.data.nickname,
+ email: req.data.email,
+ };
+
+ authorizationUtil.setItems({
+ Authorization: authorization as string,
+ Token: token,
+ userInfo: JSON.stringify(userInfo),
+ });
+ }
+
+ return req;
+ },
+ });
+
+ const loading = signLoading;
+
+ useEffect(() => {
+ if (isLogin) {
+ navigate(Routes.AdminServices);
+ }
+ }, [isLogin, navigate]);
+
+ const FormSchema = z.object({
+ email: z
+ .string()
+ .email()
+ .min(1, { message: t('emailPlaceholder') }),
+ password: z.string().min(1, { message: t('passwordPlaceholder') }),
+ remember: z.boolean().optional(),
+ });
+
+ const formId = useId();
+ const form = useForm({
+ defaultValues: {
+ email: '',
+ password: '',
+ remember: false,
+ },
+ resolver: zodResolver(FormSchema),
+ });
+
+ const onCheck: SubmitHandler> = async (params) => {
+ try {
+ const rsaPassWord = rsaPsw(params.password) as string;
+
+ const { code } = await login({
+ email: `${params.email}`.trim(),
+ password: rsaPassWord,
+ });
+
+ if (code === 0) {
+ navigate('/admin/services');
+ }
+ } catch (errorInfo) {
+ console.log('Failed:', errorInfo);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
RAGFlow
+
+
+
+ {t('loginTitle', { keyPrefix: 'admin' })}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t('login')}
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default AdminLogin;
diff --git a/web/src/pages/admin/layout.tsx b/web/src/pages/admin/layout.tsx
new file mode 100644
index 000000000..5ee2cf895
--- /dev/null
+++ b/web/src/pages/admin/layout.tsx
@@ -0,0 +1,133 @@
+import { Button } from '@/components/ui/button';
+import message from '@/components/ui/message';
+import { cn } from '@/lib/utils';
+import { Routes } from '@/routes';
+import adminService from '@/services/admin-service';
+import authorizationUtil from '@/utils/authorization-util';
+import { useMutation } from '@tanstack/react-query';
+import {
+ LucideMonitor,
+ LucideServerCrash,
+ LucideSquareUserRound,
+ LucideUserCog,
+ LucideUserStar,
+} from 'lucide-react';
+import { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { NavLink, Outlet, useLocation, useNavigate } from 'umi';
+import ThemeSwitch from './components/theme-switch';
+import { IS_ENTERPRISE } from './utils';
+
+const AdminLayout = () => {
+ const { t } = useTranslation();
+ const { pathname } = useLocation();
+ const navigate = useNavigate();
+
+ const navItems = useMemo(
+ () => [
+ {
+ path: Routes.AdminServices,
+ name: t('admin.serviceStatus'),
+ icon: ,
+ },
+ {
+ path: Routes.AdminUserManagement,
+ name: t('admin.userManagement'),
+ icon: ,
+ },
+ ...(IS_ENTERPRISE
+ ? [
+ {
+ path: Routes.AdminWhitelist,
+ name: t('admin.registrationWhitelist'),
+ icon: ,
+ },
+ {
+ path: Routes.AdminRoles,
+ name: t('admin.roles'),
+ icon: ,
+ },
+ {
+ path: Routes.AdminMonitoring,
+ name: t('admin.monitoring'),
+ icon: ,
+ },
+ ]
+ : []),
+ ],
+ [t],
+ );
+
+ const {
+ data,
+ isPending,
+ mutateAsync: logout,
+ } = useMutation({
+ mutationKey: ['adminLogout'],
+ mutationFn: async () => {
+ await adminService.logout();
+
+ message.success(t('message.logout'));
+ authorizationUtil.removeAll();
+ navigate(Routes.Admin);
+ },
+ });
+
+ return (
+
+
+
+
+
{t('admin.title')}
+
+
+
+
+ {navItems.map((it) => (
+
+
+ {it.icon}
+ {it.name}
+
+
+ ))}
+
+
+
+
+
+
+
+
+
logout()}
+ >
+ {t('header.logout')}
+
+
+
+
+
+
+ );
+};
+
+export default AdminLayout;
diff --git a/web/src/pages/admin/monitoring.tsx b/web/src/pages/admin/monitoring.tsx
new file mode 100644
index 000000000..60af64545
--- /dev/null
+++ b/web/src/pages/admin/monitoring.tsx
@@ -0,0 +1,13 @@
+import { Card, CardContent } from '@/components/ui/card';
+
+function AdminMonitoring() {
+ return (
+
+
+
+
+
+ );
+}
+
+export default AdminMonitoring;
diff --git a/web/src/pages/admin/roles.tsx b/web/src/pages/admin/roles.tsx
new file mode 100644
index 000000000..8d69a50f8
--- /dev/null
+++ b/web/src/pages/admin/roles.tsx
@@ -0,0 +1,229 @@
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { useQuery } from '@tanstack/react-query';
+
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Label } from '@/components/ui/label';
+import { LoadingButton } from '@/components/ui/loading-button';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { Switch } from '@/components/ui/switch';
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from '@/components/ui/tabs-underlined';
+import { LucideEdit3, LucideTrash2, LucideUserPlus } from 'lucide-react';
+
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+
+import { listRolesWithPermission } from '@/services/admin-service';
+
+import useCreateRoleForm from './forms/role-form';
+
+// #region FAKE DATA
+function _pickRandom(arr: T[]): T | void {
+ return arr[Math.floor(Math.random() * arr.length)];
+}
+
+const PSEUDO_TABLE_ITEMS = Array.from({ length: 20 }, () => ({
+ id: Math.random().toString(36).slice(2, 8),
+ name: 'Ahaha',
+ description: 'Ahaha description',
+ permissions: {
+ dataset: {
+ enable: _pickRandom([true, false]),
+ read: _pickRandom([true, false]),
+ write: _pickRandom([true, false]),
+ share: _pickRandom([true, false]),
+ },
+ agent: {
+ enable: _pickRandom([true, false]),
+ read: _pickRandom([true, false]),
+ write: _pickRandom([true, false]),
+ share: _pickRandom([true, false]),
+ },
+ },
+}));
+// #endregion
+
+function AdminRoles() {
+ const { t } = useTranslation();
+ const [isAddRoleModalOpen, setIsAddRoleModalOpen] = useState(false);
+
+ const { data: roleList } = useQuery({
+ queryKey: ['admin/listRolesWithPermission'],
+ queryFn: async () => (await listRolesWithPermission()).data.data.roles,
+ });
+
+ const createRoleForm = useCreateRoleForm();
+
+ const handleAddRole = (data: any) => {
+ console.log('New role data:', data);
+ // TODO: Implement actual role creation logic
+ createRoleForm.form.reset();
+ setIsAddRoleModalOpen(false);
+ };
+
+ return (
+ <>
+
+
+
+ {t('admin.roles')}
+
+ setIsAddRoleModalOpen(true)}
+ >
+
+ {t('admin.newRole')}
+
+
+
+
+ {roleList?.map((role) => {
+ const resources = Object.entries(role.permissions);
+
+ return (
+
+
+
+
+ {role.role_name}
+
+
+ {role.description || (
+
+ {t('admin.noDescription')}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {resources.map(([name]) => (
+
+ {t(`admin.resourceType.${name}`)}
+
+ ))}
+
+
+ {resources.map(([name, permission]) => (
+
+
+
+
+ {t('admin.enable')}
+
+
+
+ {}}
+ />
+ {t('admin.read')}
+
+
+
+ {}}
+ />
+ {t('admin.write')}
+
+
+
+ {}}
+ />
+ {t('admin.share')}
+
+
+
+ ))}
+
+
+
+ );
+ })}
+
+
+
+
+ {/* Add Role Modal */}
+
+
+
+ {t('admin.addNewRole')}
+
+
+
+
+
+ setIsAddRoleModalOpen(false)}
+ >
+ {t('admin.cancel')}
+
+
+
+ {t('admin.confirm')}
+
+
+
+
+ >
+ );
+}
+
+export default AdminRoles;
diff --git a/web/src/pages/admin/service-detail.tsx b/web/src/pages/admin/service-detail.tsx
new file mode 100644
index 000000000..0c53a48fd
--- /dev/null
+++ b/web/src/pages/admin/service-detail.tsx
@@ -0,0 +1,86 @@
+import { isPlainObject } from 'lodash';
+import { useMemo } from 'react';
+
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+
+interface ServiceDetailProps {
+ content?: any;
+}
+
+function ServiceDetail({ content }: ServiceDetailProps) {
+ const contentElement = useMemo(() => {
+ if (Array.isArray(content) && content.every(isPlainObject)) {
+ const headers = Object.keys(content[0]);
+
+ return (
+
+
+
+ {headers.map((header) => (
+ {header}
+ ))}
+
+
+
+
+ {content.map((item) => (
+
+ {headers.map((header: string) => (
+ {item[header] as string}
+ ))}
+
+ ))}
+
+
+ );
+ }
+
+ if (isPlainObject(content)) {
+ return (
+
+ {Object.entries(content).map(([key, value]) => (
+
+
+
+ {key}
+
+
+
+
+ {JSON.stringify(value)}
+
+
+
+ ))}
+
+ );
+ }
+
+ if (typeof content === 'string') {
+ return (
+
+
+
+ {typeof content === 'string'
+ ? content
+ : JSON.stringify(content, null, 2)}
+
+
+
+ );
+ }
+
+ return content;
+ }, [content]);
+
+ return contentElement;
+}
+
+export default ServiceDetail;
diff --git a/web/src/pages/admin/service-status.tsx b/web/src/pages/admin/service-status.tsx
new file mode 100644
index 000000000..abff04a30
--- /dev/null
+++ b/web/src/pages/admin/service-status.tsx
@@ -0,0 +1,460 @@
+import { useEffect, useMemo, useState } from 'react';
+import { Trans, useTranslation } from 'react-i18next';
+
+import {
+ createColumnHelper,
+ flexRender,
+ getCoreRowModel,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ useReactTable,
+} from '@tanstack/react-table';
+
+import {
+ LucideClipboardList,
+ LucideDot,
+ LucideFilter,
+ LucideSearch,
+ LucideSettings2,
+} from 'lucide-react';
+
+import { useQuery } from '@tanstack/react-query';
+
+import { cn } from '@/lib/utils';
+
+import { TableEmpty } from '@/components/table-skeleton';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import {
+ Card,
+ CardContent,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover';
+import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
+import { RAGFlowPagination } from '@/components/ui/ragflow-pagination';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+
+import {
+ listServices,
+ showServiceDetails,
+ type AdminService,
+} from '@/services/admin-service';
+
+import {
+ EMPTY_DATA,
+ createColumnFilterFn,
+ createFuzzySearchFn,
+ getColumnFilter,
+ getSortIcon,
+ setColumnFilter,
+} from './utils';
+
+import ServiceDetail from './service-detail';
+
+const columnHelper = createColumnHelper();
+const globalFilterFn = createFuzzySearchFn([
+ 'name',
+ 'service_type',
+]);
+
+const SERVICE_TYPE_FILTER_OPTIONS = [
+ { value: 'ragflow_server', label: 'ragflow_server' },
+ { value: 'meta_data', label: 'meta_data' },
+ { value: 'file_store', label: 'file_store' },
+ { value: 'retrieval', label: 'retrieval' },
+ { value: 'message_queue', label: 'message_queue' },
+];
+
+function AdminServiceStatus() {
+ const { t } = useTranslation();
+ const [extraInfoModalOpen, setExtraInfoModalOpen] = useState(false);
+ const [detailModalOpen, setDetailModalOpen] = useState(false);
+ const [itemToMakeAction, setItemToMakeAction] =
+ useState(null);
+
+ const { data: servicesList, isPending } = useQuery({
+ queryKey: ['admin/listServices'],
+ queryFn: async () => (await listServices()).data.data,
+ });
+
+ const {
+ data: serviceDetails,
+ isPending: isServiceDetailsPending,
+ error: serviceDetailsError,
+ } = useQuery({
+ queryKey: ['admin/serviceDetails', itemToMakeAction?.id],
+ queryFn: async () =>
+ (await showServiceDetails(itemToMakeAction?.id!)).data.data,
+ enabled: !!(itemToMakeAction && detailModalOpen),
+ retry: false,
+ refetchInterval: Infinity,
+ });
+
+ const columnDefs = useMemo(
+ () => [
+ columnHelper.accessor('id', {
+ header: t('admin.id'),
+ }),
+ columnHelper.accessor('name', {
+ header: t('admin.name'),
+ }),
+ columnHelper.accessor('service_type', {
+ header: t('admin.serviceType'),
+ filterFn: createColumnFilterFn(
+ (row, id, filterValue) => row.getValue(id) === filterValue,
+ {
+ autoRemove: (v) => !v,
+ resolveFilterValue: (v) => v || null,
+ },
+ ),
+ enableSorting: false,
+ }),
+ columnHelper.accessor('host', {
+ header: t('admin.host'),
+ cell: ({ row }) => (
+
+ {row.getValue('host')}
+
+ ),
+ }),
+ columnHelper.accessor('port', {
+ header: t('admin.port'),
+ cell: ({ row }) => (
+
+ {row.getValue('port')}
+
+ ),
+ }),
+ columnHelper.accessor('status', {
+ header: t('admin.status'),
+ cell: ({ cell }) => (
+ ()],
+ )}
+ >
+
+ {cell.getValue()}
+
+ ),
+ enableSorting: false,
+ }),
+ columnHelper.display({
+ id: 'actions',
+ header: t('admin.actions'),
+ cell: ({ row }) => (
+
+ {
+ setItemToMakeAction(row.original);
+ setExtraInfoModalOpen(true);
+ }}
+ >
+
+
+
+ {
+ setItemToMakeAction(row.original);
+ setDetailModalOpen(true);
+ }}
+ >
+
+
+
+ ),
+ }),
+ ],
+ [],
+ );
+
+ const table = useReactTable({
+ data: servicesList ?? EMPTY_DATA,
+ columns: columnDefs,
+
+ globalFilterFn,
+
+ getCoreRowModel: getCoreRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ });
+
+ useEffect(() => {
+ if (detailModalOpen && serviceDetailsError) {
+ setDetailModalOpen(false);
+ }
+ }, [detailModalOpen, serviceDetailsError]);
+
+ return (
+ <>
+
+
+
+ {t('admin.serviceStatus')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t('admin.serviceType')}
+
+
+
+ setColumnFilter(table, 'service_type', value)
+ }
+ >
+
+
+ {t('admin.all')}
+
+
+ {SERVICE_TYPE_FILTER_OPTIONS.map(({ label, value }) => (
+
+
+ {label}
+
+ ))}
+
+
+
+
+
+ table.resetColumnFilters()}
+ >
+ {t('admin.reset')}
+
+
+
+
+
+
+
+ table.setGlobalFilter(e.target.value)}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => (
+
+ {header.isPlaceholder ? null : header.column.getCanSort() ? (
+
+ {flexRender(
+ header.column.columnDef.header,
+ header.getContext(),
+ )}
+ {getSortIcon(header.column.getIsSorted())}
+
+ ) : (
+ flexRender(
+ header.column.columnDef.header,
+ header.getContext(),
+ )
+ )}
+
+ ))}
+
+ ))}
+
+
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext(),
+ )}
+
+ ))}
+
+ ))
+ ) : (
+
+ )}
+
+
+
+
+
+ {
+ table.setPagination({
+ pageIndex: page - 1,
+ pageSize,
+ });
+ }}
+ />
+
+
+
+
+ {/* Extra info modal*/}
+
+ {
+ if (!extraInfoModalOpen) {
+ setItemToMakeAction(null);
+ }
+ }}
+ >
+
+ {t('admin.extraInfo')}
+
+
+
+
+
+
+ {JSON.stringify(itemToMakeAction?.extra ?? {}, null, 2)}
+
+
+
+
+
+
+ setExtraInfoModalOpen(false)}
+ >
+ {t('admin.close')}
+
+
+
+
+
+ {/* Service details modal */}
+
+ {
+ if (!detailModalOpen) {
+ setItemToMakeAction(null);
+ }
+ }}
+ >
+
+
+
+ {{ name: itemToMakeAction?.name }}
+
+
+
+
+
+
+
+
+
+ {
+ setDetailModalOpen(false);
+ }}
+ >
+ {t('admin.close')}
+
+
+
+
+ >
+ );
+}
+
+export default AdminServiceStatus;
diff --git a/web/src/pages/admin/user-detail.tsx b/web/src/pages/admin/user-detail.tsx
new file mode 100644
index 000000000..f92e478cf
--- /dev/null
+++ b/web/src/pages/admin/user-detail.tsx
@@ -0,0 +1,433 @@
+import { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useNavigate, useParams } from 'umi';
+
+import { LucideArrowLeft, LucideDot, LucideUser2 } from 'lucide-react';
+
+import { useQuery } from '@tanstack/react-query';
+import {
+ createColumnHelper,
+ flexRender,
+ getCoreRowModel,
+ getSortedRowModel,
+ useReactTable,
+} from '@tanstack/react-table';
+
+import { cn } from '@/lib/utils';
+import { Routes } from '@/routes';
+
+import { Avatar } from '@/components/ui/avatar';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardHeader } from '@/components/ui/card';
+import { RAGFlowPagination } from '@/components/ui/ragflow-pagination';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from '@/components/ui/tabs-underlined';
+
+import {
+ getUserDetails,
+ listUserAgents,
+ listUserDatasets,
+ type AdminService,
+} from '@/services/admin-service';
+
+import EnterpriseFeature from './components/enterprise-feature';
+import { getSortIcon, parseBooleanish } from './utils';
+
+const ASSET_NAMES = ['dataset', 'flow'];
+
+const datasetColumnHelper =
+ createColumnHelper();
+const agentColumnHelper = createColumnHelper();
+
+function UserDatasetTable(props: {
+ data?: AdminService.ListUserDatasetItem[];
+}) {
+ const { t } = useTranslation();
+
+ const columnDefs = useMemo(
+ () => [
+ datasetColumnHelper.accessor('name', {
+ header: t('admin.name'),
+ enableSorting: false,
+ }),
+ datasetColumnHelper.accessor('status', {
+ header: t('admin.status'),
+ cell: ({ cell }) => {
+ return (
+
+
+ {t(
+ parseBooleanish(cell.getValue())
+ ? 'admin.active'
+ : 'admin.inactive',
+ )}
+
+ );
+ },
+ enableSorting: false,
+ }),
+ datasetColumnHelper.accessor('chunk_num', {
+ header: t('admin.chunkNum'),
+ }),
+ datasetColumnHelper.accessor('doc_num', {
+ header: t('admin.docNum'),
+ }),
+ datasetColumnHelper.accessor('token_num', {
+ header: t('admin.tokenNum'),
+ }),
+ datasetColumnHelper.accessor('language', {
+ header: t('admin.language'),
+ enableSorting: false,
+ }),
+ datasetColumnHelper.accessor('create_date', {
+ header: t('admin.createDate'),
+ }),
+ datasetColumnHelper.accessor('update_date', {
+ header: t('admin.updateDate'),
+ }),
+ datasetColumnHelper.accessor('permission', {
+ header: t('admin.permission'),
+ enableSorting: false,
+ }),
+ ],
+ [t],
+ );
+
+ const table = useReactTable({
+ data: props.data ?? [],
+ columns: columnDefs,
+
+ getCoreRowModel: getCoreRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ });
+
+ return (
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => (
+
+ {header.isPlaceholder ? null : header.column.getCanSort() ? (
+
+ {flexRender(
+ header.column.columnDef.header,
+ header.getContext(),
+ )}
+ {getSortIcon(header.column.getIsSorted())}
+
+ ) : (
+ flexRender(
+ header.column.columnDef.header,
+ header.getContext(),
+ )
+ )}
+
+ ))}
+
+ ))}
+
+
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ ))}
+
+ ))
+ ) : (
+
+
+ {t('common.noData')}
+
+
+ )}
+
+
+
+ {
+ table.setPagination({
+ pageIndex: page - 1,
+ pageSize,
+ });
+ }}
+ />
+
+ );
+}
+
+function UserAgentTable(props: { data?: AdminService.ListUserAgentItem[] }) {
+ const { t } = useTranslation();
+
+ const columnDefs = useMemo(
+ () => [
+ agentColumnHelper.accessor('title', {
+ header: t('admin.agentTitle'),
+ }),
+ agentColumnHelper.accessor('permission', {
+ header: t('admin.permission'),
+ }),
+ agentColumnHelper.accessor('canvas_category', {
+ header: t('admin.canvasCategory'),
+ }),
+ ],
+ [t],
+ );
+
+ const table = useReactTable({
+ data: props.data ?? [],
+ columns: columnDefs,
+
+ getCoreRowModel: getCoreRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ });
+
+ return (
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => (
+
+ {header.isPlaceholder ? null : (
+ <>
+ {flexRender(
+ header.column.columnDef.header,
+ header.getContext(),
+ )}
+ {/* {header.column.getCanFilter() && (
+
+
+
+ )} */}
+ >
+ )}
+
+ ))}
+
+ ))}
+
+
+
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ ))}
+
+ ))
+ ) : (
+
+
+ {t('common.noData')}
+
+
+ )}
+
+
+
+ {
+ table.setPagination({
+ pageIndex: page - 1,
+ pageSize,
+ });
+ }}
+ />
+
+ );
+}
+
+function AdminUserDetail() {
+ const navigate = useNavigate();
+ const { t } = useTranslation();
+ const { id } = useParams();
+
+ const { data: { detail, datasets, agents } = {} } = useQuery({
+ queryKey: ['admin/userDetail', id],
+ queryFn: async () => {
+ const [userDetails, userDatasets, userAgents] = await Promise.all([
+ getUserDetails(id!),
+ listUserDatasets(id!),
+ listUserAgents(id!),
+ ]);
+
+ return {
+ detail: userDetails.data.data[0],
+ datasets: userDatasets.data.data,
+ agents: userAgents.data.data,
+ };
+ },
+ enabled: !!id,
+ });
+
+ return (
+
+
+ navigate(`${Routes.AdminUserManagement}`)}
+ >
+
+ {t('admin.userManagement')}
+
+
+
+
+
+
+
+ {detail?.email
+ .split('@')[0]
+ .replace(/[^0-9a-z]/gi, '')
+ .slice(0, 2) || }
+
+
+ {detail?.email}
+
+
+
+ {t(
+ parseBooleanish(detail?.is_active)
+ ? 'admin.active'
+ : 'admin.inactive',
+ )}
+
+
+
+ {() => (
+
+ {detail?.role}
+
+ )}
+
+
+
+
+
+
+ {t('admin.lastLoginTime')}
+
+
{detail?.last_login_time}
+
+
+
+
+ {t('admin.createTime')}
+
+
{detail?.create_date}
+
+
+
+
+ {t('admin.lastUpdateTime')}
+
+
{detail?.update_date}
+
+
+
+
+ {t('admin.language')}
+
+
{detail?.language}
+
+
+
+
+ {t('admin.isAnonymous')}
+
+
{t(detail?.is_anonymous ? 'admin.yes' : 'admin.no')}
+
+
+
+
+
+
+
+ {ASSET_NAMES.map((name) => (
+
+ {t(`header.${name}`)}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default AdminUserDetail;
diff --git a/web/src/pages/admin/users.tsx b/web/src/pages/admin/users.tsx
new file mode 100644
index 000000000..ebaf91b35
--- /dev/null
+++ b/web/src/pages/admin/users.tsx
@@ -0,0 +1,691 @@
+import { useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useNavigate } from 'umi';
+
+import {
+ createColumnHelper,
+ flexRender,
+ getCoreRowModel,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ useReactTable,
+} from '@tanstack/react-table';
+
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+
+import {
+ LucideClipboardList,
+ LucideDot,
+ LucideTrash2,
+ LucideUserLock,
+ LucideUserPlus,
+} from 'lucide-react';
+
+import { cn } from '@/lib/utils';
+import { rsaPsw } from '@/utils';
+
+import { TableEmpty } from '@/components/table-skeleton';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import {
+ Card,
+ CardContent,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { LoadingButton } from '@/components/ui/loading-button';
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover';
+import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
+import { RAGFlowPagination } from '@/components/ui/ragflow-pagination';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { Switch } from '@/components/ui/switch';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import { Routes } from '@/routes';
+import { LucideFilter, LucideSearch } from 'lucide-react';
+
+import useChangePasswordForm from './forms/change-password-form';
+import useCreateUserForm from './forms/user-form';
+
+import {
+ createUser,
+ deleteUser,
+ listRoles,
+ listUsers,
+ updateUserPassword,
+ updateUserRole,
+ updateUserStatus,
+ type AdminService,
+} from '@/services/admin-service';
+
+import {
+ createColumnFilterFn,
+ createFuzzySearchFn,
+ EMPTY_DATA,
+ IS_ENTERPRISE,
+ parseBooleanish,
+} from './utils';
+
+import EnterpriseFeature from './components/enterprise-feature';
+
+const columnHelper = createColumnHelper();
+const globalFilterFn = createFuzzySearchFn([
+ 'email',
+ 'nickname',
+]);
+
+const STATUS_FILTER_OPTIONS = [
+ { value: '', label: 'admin.all' },
+ { value: 'active', label: 'admin.active' },
+ { value: 'inactive', label: 'admin.inactive' },
+];
+
+function AdminUserManagement() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+
+ const [deleteModalOpen, setDeleteModalOpen] = useState(false);
+ const [passwordModalOpen, setPasswordModalOpen] = useState(false);
+ const [createUserModalOpen, setCreateUserModalOpen] = useState(false);
+ const [userToMakeAction, setUserToMakeAction] =
+ useState(null);
+
+ const changePasswordForm = useChangePasswordForm();
+ const createUserForm = useCreateUserForm();
+
+ const { data: roleList } = useQuery({
+ queryKey: ['admin/listRoles'],
+ queryFn: async () => (await listRoles()).data.data.roles,
+ });
+
+ const { data: usersList, isPending } = useQuery({
+ queryKey: ['admin/listUsers'],
+ queryFn: async () => (await listUsers()).data.data,
+ });
+
+ // Delete user mutation
+ const deleteUserMutation = useMutation({
+ mutationFn: deleteUser,
+ onSuccess: () => {
+ // message.success(t('admin.userDeletedSuccessfully'));
+ queryClient.invalidateQueries({ queryKey: ['admin/listUsers'] });
+ setDeleteModalOpen(false);
+ setUserToMakeAction(null);
+ },
+ });
+
+ // Change password mutation
+ const changePasswordMutation = useMutation({
+ mutationFn: ({ email, password }: { email: string; password: string }) =>
+ updateUserPassword(email, password),
+ onSuccess: () => {
+ // message.success(t('admin.passwordChangedSuccessfully'));
+ setPasswordModalOpen(false);
+ setUserToMakeAction(null);
+ },
+ });
+
+ // Update user role mutation
+ const updateUserRoleMutation = useMutation({
+ mutationFn: ({ email, role }: { email: string; role: string }) =>
+ updateUserRole(email, role),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['admin/listUsers'] });
+ },
+ });
+
+ // Create user mutation
+ const createUserMutation = useMutation({
+ mutationFn: async ({
+ email,
+ password,
+ role,
+ }: {
+ email: string;
+ password: string;
+ role?: string;
+ }) => {
+ await createUser(email, password);
+
+ if (IS_ENTERPRISE && role) {
+ await updateUserRoleMutation.mutateAsync({ email, role });
+ }
+ },
+ onSuccess: () => {
+ // message.success(t('admin.userCreatedSuccessfully'));
+ queryClient.invalidateQueries({ queryKey: ['admin/listUsers'] });
+ setCreateUserModalOpen(false);
+ createUserForm.form.reset();
+ },
+ });
+
+ // Update user status mutation
+ const updateUserStatusMutation = useMutation({
+ mutationFn: (data: { email: string; isActive: boolean }) =>
+ updateUserStatus(data.email, data.isActive ? 'on' : 'off'),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['admin/listUsers'] });
+ },
+ });
+
+ const columnDefs = useMemo(
+ () => [
+ columnHelper.accessor('email', {
+ header: t('admin.email'),
+ }),
+ columnHelper.accessor('nickname', {
+ header: t('admin.nickname'),
+ }),
+
+ ...(IS_ENTERPRISE
+ ? [
+ columnHelper.accessor('role', {
+ header: t('admin.role'),
+ cell: ({ row, cell }) => (
+ {
+ if (!updateUserRoleMutation.isPending) {
+ updateUserRoleMutation.mutate({
+ email: row.original.email,
+ role: value,
+ });
+ }
+ }}
+ disabled={updateUserRoleMutation.isPending}
+ >
+
+
+
+
+
+ {roleList?.map(({ id, role_name }) => (
+
+ {role_name}
+
+ ))}
+
+
+ ),
+ filterFn: createColumnFilterFn(
+ (row, id, filterValue) => row.getValue(id) === filterValue,
+ {
+ autoRemove: (v) => !v,
+ },
+ ),
+ }),
+ ]
+ : []),
+
+ 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',
+ )}
+
+ ),
+ filterFn: createColumnFilterFn(
+ (row, id, filterValue) => row.getValue(id) === filterValue,
+ {
+ autoRemove: (v) => !v,
+ resolveFilterValue: (v) =>
+ v ? (v === 'active' ? '1' : '0') : null,
+ },
+ ),
+ }),
+ columnHelper.display({
+ id: 'actions',
+ header: t('admin.actions'),
+ cell: ({ row }) => (
+
+
+ navigate(`${Routes.AdminUserManagement}/${row.original.email}`)
+ }
+ >
+
+
+ {
+ setUserToMakeAction(row.original);
+ setPasswordModalOpen(true);
+ }}
+ >
+
+
+ {
+ setUserToMakeAction(row.original);
+ setDeleteModalOpen(true);
+ }}
+ >
+
+
+
+ ),
+ }),
+ ],
+ [
+ roleList,
+ t,
+ navigate,
+ updateUserStatusMutation.isPending,
+ updateUserRoleMutation.isPending,
+ ],
+ );
+
+ const table = useReactTable({
+ data: usersList ?? EMPTY_DATA,
+ columns: columnDefs,
+
+ globalFilterFn,
+
+ getCoreRowModel: getCoreRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ });
+
+ return (
+ <>
+
+
+
+ {t('admin.userManagement')}
+
+
+
+
+
+
+
+
+
+
+
+
+ {() => (
+
+
+ {t('admin.role')}
+
+
+
+ table.getColumn('role')?.setFilterValue(value)
+ }
+ >
+
+
+ {t('admin.all')}
+
+
+ {roleList?.map(({ id, role_name }) => (
+
+
+ {role_name}
+
+ ))}
+
+
+ )}
+
+
+
+ {t('admin.status')}
+
+
+ table.getColumn('is_active')?.setFilterValue(value)
+ }
+ >
+ {STATUS_FILTER_OPTIONS.map(({ label, value }) => (
+
+
+ {t(label)}
+
+ ))}
+
+
+
+
+
+ table.resetColumnFilters()}
+ >
+ {t('admin.reset')}
+
+
+
+
+
+
+
+
+ table.setGlobalFilter(e.target.value)}
+ />
+
+
+
setCreateUserModalOpen(true)}
+ >
+
+ {t('admin.newUser')}
+
+
+
+
+
+
+
+
+
+
+
+ {() => }
+
+
+
+
+
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => (
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext(),
+ )}
+
+ ))}
+
+ ))}
+
+
+
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext(),
+ )}
+
+ ))}
+
+ ))
+ ) : (
+
+ )}
+
+
+
+
+
+ {
+ table.setPagination({
+ pageIndex: page - 1,
+ pageSize,
+ });
+ }}
+ />
+
+
+
+
+ {/* Delete Confirmation Modal */}
+
+
+
+ {t('admin.deleteUser')}
+
+
+
+
+ {t('admin.deleteUserConfirmation')}
+
+
+ {userToMakeAction?.email}
+
+
+
+
+
+ setDeleteModalOpen(false)}
+ disabled={deleteUserMutation.isPending}
+ >
+ {t('admin.cancel')}
+
+
+
+ deleteUserMutation.mutate(userToMakeAction?.email || '')
+ }
+ disabled={deleteUserMutation.isPending}
+ loading={deleteUserMutation.isPending}
+ >
+ {t('admin.delete')}
+
+
+
+
+
+ {/* Change Password Modal */}
+
+
+
+ {t('admin.changePassword')}
+
+
+
+ {
+ if (userToMakeAction) {
+ changePasswordMutation.mutate({
+ email: userToMakeAction.email,
+ password: rsaPsw(newPassword) as string,
+ });
+ }
+ }}
+ />
+
+
+
+ {
+ setPasswordModalOpen(false);
+ setUserToMakeAction(null);
+ }}
+ disabled={changePasswordMutation.isPending}
+ >
+ {t('admin.cancel')}
+
+
+
+ {t('admin.changePassword')}
+
+
+
+
+
+ {/* Create User Modal */}
+ {
+ setCreateUserModalOpen(false);
+ createUserForm.form.reset();
+ }}
+ >
+
+
+ {t('admin.createNewUser')}
+
+
+
+ {
+ createUserMutation.mutate({
+ email: email,
+ password: rsaPsw(password) as string,
+ });
+ }}
+ />
+
+
+
+ {
+ setCreateUserModalOpen(false);
+ createUserForm.form.reset();
+ }}
+ disabled={createUserMutation.isPending}
+ >
+ {t('admin.cancel')}
+
+
+
+ {t('admin.confirm')}
+
+
+
+
+ >
+ );
+}
+
+export default AdminUserManagement;
diff --git a/web/src/pages/admin/utils.tsx b/web/src/pages/admin/utils.tsx
new file mode 100644
index 000000000..1bb42ca91
--- /dev/null
+++ b/web/src/pages/admin/utils.tsx
@@ -0,0 +1,85 @@
+import {
+ ColumnFilterAutoRemoveTestFn,
+ FilterFn,
+ Row,
+ RowData,
+ SortDirection,
+ Table,
+ TransformFilterValueFn,
+} from '@tanstack/react-table';
+import { LucideSortAsc, LucideSortDesc } from 'lucide-react';
+
+export function parseBooleanish(value: any): boolean {
+ return typeof value === 'string'
+ ? /^(1|[Tt]rue|[Oo]n|[Yy](es)?)$/.test(value)
+ : !!value;
+}
+
+export function createFuzzySearchFn(
+ columns: (keyof TData)[] = [],
+) {
+ return (row: Row, columnId: string, filterValue: string) => {
+ const searchText = filterValue.trim().toLowerCase();
+
+ return columns
+ .map((n) =>
+ row
+ .getValue(n as string)
+ .trim()
+ .toLowerCase(),
+ )
+ .some((v) => v.includes(searchText));
+ };
+}
+
+export function createColumnFilterFn(
+ filterFn: FilterFn,
+ options: {
+ resolveFilterValue?: TransformFilterValueFn;
+ autoRemove?: ColumnFilterAutoRemoveTestFn;
+ },
+) {
+ return Object.assign(filterFn, options) as FilterFn;
+}
+
+export function getColumnFilter(
+ table: Table,
+ columnId: string,
+) {
+ return table
+ .getState()
+ .columnFilters.find((filter) => filter.id === columnId);
+}
+
+export function setColumnFilter(
+ table: Table,
+ columnId: string,
+ value?: unknown,
+) {
+ const otherColumnFilters = table
+ .getState()
+ .columnFilters.filter((filter) => filter.id !== columnId);
+
+ if (value == null) {
+ table.setColumnFilters(otherColumnFilters);
+ } else {
+ table.setColumnFilters([
+ ...otherColumnFilters,
+ {
+ id: columnId,
+ value,
+ },
+ ]);
+ }
+}
+
+export function getSortIcon(sorting: false | SortDirection) {
+ return {
+ asc: ,
+ desc: ,
+ }[sorting as string];
+}
+
+export const EMPTY_DATA = Object.freeze([]) as any[];
+export const IS_ENTERPRISE =
+ process.env.UMI_APP_RAGFLOW_ENTERPRISE === 'RAGFLOW_ENTERPRISE';
diff --git a/web/src/pages/admin/whitelist.tsx b/web/src/pages/admin/whitelist.tsx
new file mode 100644
index 000000000..55db9a304
--- /dev/null
+++ b/web/src/pages/admin/whitelist.tsx
@@ -0,0 +1,522 @@
+import { TableEmpty } from '@/components/table-skeleton';
+import { Button } from '@/components/ui/button';
+import {
+ Card,
+ CardContent,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Input } from '@/components/ui/input';
+import { LoadingButton } from '@/components/ui/loading-button';
+import { RAGFlowPagination } from '@/components/ui/ragflow-pagination';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import {
+ createColumnHelper,
+ flexRender,
+ getCoreRowModel,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ useReactTable,
+} from '@tanstack/react-table';
+import {
+ LucideDownload,
+ LucidePlus,
+ LucideSearch,
+ LucideTrash2,
+ LucideUpload,
+ LucideUserPen,
+} from 'lucide-react';
+import { useEffect, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import useCreateEmailForm from './forms/email-form';
+import useImportExcelForm from './forms/import-excel-form';
+import { EMPTY_DATA, createFuzzySearchFn } from './utils';
+
+// #region FAKE DATA
+function _pickRandom(arr: T[]): T | void {
+ return arr[Math.floor(Math.random() * arr.length)];
+}
+
+const PSEUDO_TABLE_ITEMS = Array.from({ length: 20 }, () => ({
+ id: Math.random().toString(36).slice(2, 8),
+ email: `${Math.random().toString(36).slice(2, 6)}@example.com`,
+ created_by: _pickRandom(['Alice', 'Bob', 'Carol', 'Dave']) || 'System',
+ created_at: Date.now() - Math.floor(Math.random() * 1000 * 60 * 60 * 24 * 30),
+}));
+// #endregion
+
+const columnHelper = createColumnHelper<(typeof PSEUDO_TABLE_ITEMS)[0]>();
+const globalFilterFn = createFuzzySearchFn<(typeof PSEUDO_TABLE_ITEMS)[0]>([
+ 'email',
+]);
+
+function AdminWhitelist() {
+ const { t } = useTranslation();
+ const queryClient = useQueryClient();
+
+ const createEmailForm = useCreateEmailForm();
+ const importExcelForm = useImportExcelForm();
+
+ const [emailToMakeAction, setEmailToMakeAction] = useState(
+ null,
+ );
+ const [deleteModalOpen, setDeleteModalOpen] = useState(false);
+ const [createModalOpen, setCreateModalOpen] = useState(false);
+ const [editModalOpen, setEditModalOpen] = useState(false);
+
+ const [importModalOpen, setImportModalOpen] = useState(false);
+
+ // Reset form when editing a different email
+ useEffect(() => {
+ if (emailToMakeAction && editModalOpen) {
+ createEmailForm.form.setValue('email', emailToMakeAction);
+ }
+ }, [emailToMakeAction, editModalOpen, createEmailForm.form]);
+
+ const { isPending: isCreating, mutateAsync: createEmail } = useMutation({
+ mutationFn: async (data: { email: string }) => {
+ /* create email API call */
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['admin/whitelist'] });
+ setCreateModalOpen(false);
+ setEmailToMakeAction(null);
+ createEmailForm.form.reset();
+ },
+ onError: (error) => {
+ console.error('Error creating email:', error);
+ },
+ });
+
+ const { isPending: isEditing, mutateAsync: updateEmail } = useMutation({
+ mutationFn: async (data: { email: string }) => {
+ /* update email API call */
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['admin/whitelist'] });
+ setEditModalOpen(false);
+ setEmailToMakeAction(null);
+ createEmailForm.form.reset();
+ },
+ });
+
+ const { isPending: isDeleting, mutateAsync: deleteEmail } = useMutation({
+ mutationFn: async (data: { email: string }) => {
+ /* delete email API call */
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['admin/whitelist'] });
+ setDeleteModalOpen(false);
+ setEmailToMakeAction(null);
+ },
+ onError: (error) => {
+ console.error('Error deleting email:', error);
+ },
+ });
+
+ const { isPending: isImporting, mutateAsync: importExcel } = useMutation({
+ mutationFn: async (data: {
+ file: FileList;
+ overwriteExisting: boolean;
+ }) => {
+ /* import Excel API call */
+ console.log(
+ 'Importing Excel file:',
+ data.file[0]?.name,
+ 'Overwrite:',
+ data.overwriteExisting,
+ );
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['admin/whitelist'] });
+ setImportModalOpen(false);
+ importExcelForm.form.reset();
+ },
+ onError: (error) => {
+ console.error('Error importing Excel:', error);
+ },
+ });
+
+ const columnDefs = useMemo(
+ () => [
+ columnHelper.accessor('email', {
+ header: 'Email',
+ }),
+ columnHelper.accessor('created_by', {
+ header: 'Created by',
+ }),
+ columnHelper.accessor('created_at', {
+ header: 'Created date',
+ cell: ({ row }) =>
+ new Date(row.getValue('created_at') as number).toLocaleString(),
+ }),
+ columnHelper.display({
+ id: 'actions',
+ header: 'Actions',
+ cell: ({ row }) => (
+
+ {
+ setEmailToMakeAction(row.original.email);
+ setEditModalOpen(true);
+ }}
+ >
+
+
+ {
+ setEmailToMakeAction(row.original.email);
+ setDeleteModalOpen(true);
+ }}
+ >
+
+
+
+ ),
+ }),
+ ],
+ [],
+ );
+
+ const table = useReactTable({
+ data: PSEUDO_TABLE_ITEMS ?? EMPTY_DATA,
+ columns: columnDefs,
+
+ globalFilterFn,
+
+ getCoreRowModel: getCoreRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ });
+
+ return (
+ <>
+
+
+
+ {t('admin.whitelistManagement')}
+
+
+
+
+ table.setGlobalFilter(e.target.value)}
+ />
+
+
+
+
+ {t('admin.exportAsExcel')}
+
+
+
setImportModalOpen(true)}
+ >
+
+ {t('admin.importFromExcel')}
+
+
+
setCreateModalOpen(true)}
+ >
+
+ {t('admin.createEmail')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => (
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext(),
+ )}
+
+ ))}
+
+ ))}
+
+
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext(),
+ )}
+
+ ))}
+
+ ))
+ ) : (
+
+ )}
+
+
+
+
+
+ {
+ table.setPagination({
+ pageIndex: page - 1,
+ pageSize,
+ });
+ }}
+ />
+
+
+
+
+ {/* Delete Confirmation Modal */}
+
+ {
+ if (!deleteModalOpen) {
+ setEmailToMakeAction(null);
+ }
+ }}
+ >
+
+ {t('admin.deleteEmail')}
+
+
+
+
+ {t('admin.deleteWhitelistEmailConfirmation')}
+
+
+ {emailToMakeAction}
+
+
+
+
+
+ setDeleteModalOpen(false)}
+ disabled={isDeleting}
+ >
+ {t('admin.cancel')}
+
+
+ {
+ deleteEmail({ email: emailToMakeAction! });
+ }}
+ disabled={isDeleting}
+ loading={isDeleting}
+ >
+ {t('admin.delete')}
+
+
+
+
+
+ {/* Create Email Modal */}
+ {
+ setCreateModalOpen(false);
+ createEmailForm.form.reset();
+ }}
+ >
+
+
+ {t('admin.createEmail')}
+
+
+
+
+
+ {
+ setCreateModalOpen(false);
+ createEmailForm.form.reset();
+ }}
+ disabled={isCreating}
+ >
+ {t('admin.cancel')}
+
+
+
+ {t('admin.confirm')}
+
+
+
+
+
+ {/* Edit Email Modal */}
+ {
+ setEditModalOpen(false);
+ setEmailToMakeAction(null);
+ createEmailForm.form.reset();
+ }}
+ >
+ {
+ if (!editModalOpen) {
+ setEmailToMakeAction(null);
+ createEmailForm.form.reset();
+ }
+ }}
+ >
+
+ {t('admin.editEmail')}
+
+
+
+
+
+ {
+ setEditModalOpen(false);
+ setEmailToMakeAction(null);
+ createEmailForm.form.reset();
+ }}
+ disabled={isEditing}
+ >
+ {t('admin.cancel')}
+
+
+
+ {t('admin.confirm')}
+
+
+
+
+
+ {/* Import Excel Modal */}
+
+
+
+ {t('admin.importWhitelist')}
+
+
+
+
+
+ {
+ setImportModalOpen(false);
+ importExcelForm.form.reset();
+ }}
+ disabled={isImporting}
+ >
+ {t('admin.cancel')}
+
+
+
+ {t('admin.import')}
+
+
+
+
+ >
+ );
+}
+
+export default AdminWhitelist;
diff --git a/web/src/routes.ts b/web/src/routes.ts
index 982a71bd4..eca98dcb6 100644
--- a/web/src/routes.ts
+++ b/web/src/routes.ts
@@ -1,3 +1,5 @@
+import { IS_ENTERPRISE } from './pages/admin/utils';
+
export enum Routes {
Root = '/',
Login = '/login-next',
@@ -47,6 +49,12 @@ export enum Routes {
DataSetOverview = '/dataset-overview',
DataSetSetting = '/dataset-setting',
DataflowResult = '/dataflow-result',
+ Admin = '/admin',
+ AdminServices = `${Admin}/services`,
+ AdminUserManagement = `${Admin}/users`,
+ AdminWhitelist = `${Admin}/whitelist`,
+ AdminRoles = `${Admin}/roles`,
+ AdminMonitoring = `${Admin}/monitoring`,
}
const routes = [
@@ -394,6 +402,56 @@ const routes = [
},
],
},
+
+ // Admin routes
+ {
+ path: Routes.Admin,
+ component: `@/pages/admin`,
+ layout: false,
+ },
+ {
+ path: `${Routes.AdminUserManagement}/:id`,
+ layout: false,
+ wrappers: ['@/wrappers/authAdmin'],
+ component: `@/pages/admin/user-detail`,
+ },
+ {
+ path: Routes.Admin,
+ component: `@/pages/admin/layout`,
+ layout: false,
+ routes: [
+ {
+ path: Routes.AdminServices,
+ component: `@/pages/admin/service-status`,
+ wrappers: ['@/wrappers/authAdmin'],
+ },
+ {
+ path: Routes.AdminUserManagement,
+ component: `@/pages/admin/users`,
+ wrappers: ['@/wrappers/authAdmin'],
+ },
+
+ ...(IS_ENTERPRISE
+ ? [
+ {
+ path: Routes.AdminWhitelist,
+ component: `@/pages/admin/whitelist`,
+ wrappers: ['@/wrappers/authAdmin'],
+ },
+ {
+ path: Routes.AdminRoles,
+ component: `@/pages/admin/roles`,
+ wrappers: ['@/wrappers/authAdmin'],
+ },
+ {
+ path: Routes.AdminMonitoring,
+ component: `@/pages/admin/monitoring`,
+ wrappers: ['@/wrappers/authAdmin'],
+ },
+ ]
+ : []),
+ ],
+ },
];
export default routes;
diff --git a/web/src/services/admin-service.ts b/web/src/services/admin-service.ts
new file mode 100644
index 000000000..329ac9e31
--- /dev/null
+++ b/web/src/services/admin-service.ts
@@ -0,0 +1,379 @@
+import { message, notification } from 'antd';
+import axios from 'axios';
+import { Navigate } from 'umi';
+
+import { Authorization } from '@/constants/authorization';
+import i18n from '@/locales/config';
+import { Routes } from '@/routes';
+import api from '@/utils/api';
+import authorizationUtil, {
+ getAuthorization,
+} from '@/utils/authorization-util';
+import { convertTheKeysOfTheObjectToSnake } from '@/utils/common-util';
+import { ResultCode, RetcodeMessage } from '@/utils/request';
+
+const request = axios.create({
+ timeout: 300000,
+});
+
+request.interceptors.request.use((config) => {
+ const data = convertTheKeysOfTheObjectToSnake(config.data);
+ const params = convertTheKeysOfTheObjectToSnake(config.params) as any;
+
+ const newConfig = { ...config, data, params };
+
+ // @ts-ignore
+ if (!newConfig.skipToken) {
+ newConfig.headers.set(Authorization, getAuthorization());
+ }
+
+ return newConfig;
+});
+
+request.interceptors.response.use(
+ (response) => {
+ if (response.config.responseType === 'blob') {
+ return response;
+ }
+
+ const { data } = response ?? {};
+
+ if (data?.code === 100) {
+ message.error(data?.message);
+ } else if (data?.code === 401) {
+ notification.error({
+ message: data?.message,
+ description: data?.message,
+ duration: 3,
+ });
+
+ authorizationUtil.removeAll();
+ Navigate({ to: Routes.Admin });
+ } else if (data?.code && data.code !== 0) {
+ notification.error({
+ message: `${i18n.t('message.hint')}: ${data?.code}`,
+ description: data?.message,
+ duration: 3,
+ });
+ }
+
+ return response;
+ },
+ (error) => {
+ const { response, message } = error;
+ const { data } = response ?? {};
+
+ if (error.message === 'Failed to fetch') {
+ notification.error({
+ description: i18n.t('message.networkAnomalyDescription'),
+ message: i18n.t('message.networkAnomaly'),
+ });
+ } else if (data?.code === 100) {
+ message.error(data?.message);
+ } else if (data?.code === 401) {
+ notification.error({
+ message: data?.message,
+ description: data?.message,
+ duration: 3,
+ });
+
+ authorizationUtil.removeAll();
+ Navigate({ to: Routes.Admin });
+ } else if (data?.code && data.code !== 0) {
+ notification.error({
+ message: `${i18n.t('message.hint')}: ${data?.code}`,
+ description: data?.message,
+ duration: 3,
+ });
+ } else if (response.status) {
+ notification.error({
+ message: `${i18n.t('message.requestError')} ${response.status}: ${response.config.url}`,
+ description:
+ RetcodeMessage[response.status as ResultCode] || response.statusText,
+ });
+ } else if (response.status === 413 || response?.status === 504) {
+ message.error(RetcodeMessage[response?.status as ResultCode]);
+ } else if (response.status === 401) {
+ notification.error({
+ message: response.data.message,
+ description: response.data.message,
+ duration: 3,
+ });
+ authorizationUtil.removeAll();
+ window.location.href = location.origin + '/admin';
+ }
+
+ return error;
+ },
+);
+
+const {
+ adminLogin,
+ adminLogout,
+ adminListUsers,
+ adminCreateUser,
+ adminGetUserDetails: adminShowUserDetails,
+ adminUpdateUserStatus,
+ adminUpdateUserPassword,
+ adminDeleteUser,
+ adminListUserDatasets,
+ adminListUserAgents,
+
+ adminListServices,
+ adminShowServiceDetails,
+
+ adminListRoles,
+ adminListRolesWithPermission,
+ adminCreateRole,
+ adminDeleteRole,
+ adminUpdateRoleDescription,
+ adminGetRolePermissions,
+ adminAssignRolePermissions,
+ adminRevokeRolePermissions,
+
+ adminGetUserPermissions,
+ adminUpdateUserRole,
+
+ adminListResources,
+} = api;
+
+type ResponseData = {
+ code: number;
+ message: string;
+ data: D;
+};
+
+export namespace AdminService {
+ export type LoginData = {
+ access_token: string;
+ avatar: unknown;
+ color_schema: 'Bright' | 'Dark';
+ create_date: string;
+ create_time: number;
+ email: string;
+ id: string;
+ is_active: '0' | '1';
+ is_anonymous: '0' | '1';
+ is_authenticated: '0' | '1';
+ is_superuser: boolean;
+ language: string;
+ last_login_time: string;
+ login_channel: unknown;
+ nickname: string;
+ password: string;
+ status: '0' | '1';
+ timezone: string;
+ update_date: [string];
+ update_time: [number];
+ };
+
+ export type ListUsersItem = {
+ create_date: string;
+ email: string;
+ is_active: '0' | '1';
+ is_superuser: boolean;
+ role: string;
+ nickname: string;
+ };
+
+ export type UserDetail = {
+ create_date: string;
+ email: string;
+ is_active: '0' | '1';
+ is_anonymous: '0' | '1';
+ is_superuser: boolean;
+ language: string;
+ last_login_time: string;
+ login_channel: unknown;
+ status: '0' | '1';
+ update_date: string;
+ role: string;
+ };
+
+ export type ListUserDatasetItem = {
+ chunk_num: number;
+ create_date: string;
+ doc_num: number;
+ language: string;
+ name: string;
+ permission: string;
+ status: '0' | '1';
+ token_num: number;
+ update_date: string;
+ };
+
+ export type ListUserAgentItem = {
+ canvas_category: 'agent';
+ permission: 'string';
+ title: string;
+ };
+
+ export type ListServicesItem = {
+ extra: Record;
+ host: string;
+ id: number;
+ name: string;
+ port: number;
+ service_type: string;
+ status: 'alive' | 'timeout' | 'fail';
+ };
+
+ export type ServiceDetail = {
+ service_name: string;
+ status: 'alive' | 'timeout';
+ message: string | Record | Record[];
+ };
+
+ export type PermissionData = {
+ enable: boolean;
+ read: boolean;
+ write: boolean;
+ share: boolean;
+ };
+
+ export type ListRoleItem = {
+ id: string;
+ role_name: string;
+ description: string;
+ create_date: string;
+ update_date: string;
+ };
+
+ export type ListRoleItemWithPermission = ListRoleItem & {
+ permissions: Record;
+ };
+
+ export type RoleDetailWithPermission = {
+ role: {
+ id: string;
+ name: string;
+ description: string;
+ };
+ permissions: Record;
+ };
+
+ export type RoleDetail = {
+ id: string;
+ name: string;
+ descrtiption: string;
+ create_date: string;
+ update_date: string;
+ };
+
+ export type AssignRolePermissionInput = {
+ permissions: Record>;
+ };
+
+ export type RevokeRolePermissionInput = AssignRolePermissionInput;
+
+ export type UserDetailWithPermission = {
+ user: {
+ id: string;
+ username: string;
+ role: string;
+ };
+ role_permissions: Record;
+ };
+
+ export type ResourceType = {
+ resource_types: string[];
+ };
+}
+
+export const login = (params: { email: string; password: string }) =>
+ request.post>(adminLogin, params);
+export const logout = () => request.get>(adminLogout);
+export const listUsers = () =>
+ request.get>(adminListUsers, {});
+
+export const createUser = (email: string, password: string) =>
+ request.post>(adminCreateUser, {
+ username: email,
+ password,
+ });
+export const getUserDetails = (email: string) =>
+ request.get>(
+ adminShowUserDetails(email),
+ );
+export const listUserDatasets = (email: string) =>
+ request.get>(
+ adminListUserDatasets(email),
+ );
+export const listUserAgents = (email: string) =>
+ request.get>(
+ adminListUserAgents(email),
+ );
+export const updateUserStatus = (email: string, status: 'on' | 'off') =>
+ request.put(adminUpdateUserStatus(email), { activate_status: status });
+export const updateUserPassword = (email: string, password: string) =>
+ request.put(adminUpdateUserPassword(email), { new_password: password });
+export const deleteUser = (email: string) =>
+ request.delete(adminDeleteUser(email));
+
+export const listServices = () =>
+ request.get>(adminListServices);
+export const showServiceDetails = (serviceId: number) =>
+ request.get>(
+ adminShowServiceDetails(String(serviceId)),
+ );
+
+export const createRole = (params: { roleName: string; description: string }) =>
+ request.post>(adminCreateRole, params);
+export const updateRoleDescription = (role: string, description: string) =>
+ request.put>(
+ adminUpdateRoleDescription(role),
+ { description },
+ );
+export const deleteRole = (role: string) =>
+ request.delete>>(adminDeleteRole(role));
+export const listRoles = () =>
+ request.get<
+ ResponseData<{ roles: AdminService.ListRoleItem[]; total: number }>
+ >(adminListRoles);
+export const listRolesWithPermission = () =>
+ request.get<
+ ResponseData<{
+ roles: AdminService.ListRoleItemWithPermission[];
+ total: number;
+ }>
+ >(adminListRolesWithPermission);
+export const getRolePermissions = (role: string) =>
+ request.get>(
+ adminGetRolePermissions(role),
+ );
+export const assignRolePermissions = (
+ role: string,
+ params: AdminService.AssignRolePermissionInput,
+) =>
+ request.post>(adminAssignRolePermissions(role), params);
+export const revokeRolePermissions = (
+ role: string,
+ params: AdminService.RevokeRolePermissionInput,
+) =>
+ request.delete>(adminRevokeRolePermissions(role), {
+ data: params,
+ });
+
+export const updateUserRole = (username: string, role: string) =>
+ request.put>(adminUpdateUserRole(username), {
+ role_name: role,
+ });
+export const getUserPermissions = (username: string) =>
+ request.get>(
+ adminGetUserPermissions(username),
+ );
+export const listResources = () =>
+ request.get>(adminListResources);
+
+export default {
+ login,
+ logout,
+ listUsers,
+ createUser,
+ showUserDetails: getUserDetails,
+ updateUserStatus,
+ updateUserPassword,
+ deleteUser,
+ listUserDatasets,
+ listUserAgents,
+};
diff --git a/web/src/utils/api.ts b/web/src/utils/api.ts
index 273501a2a..6af46198e 100644
--- a/web/src/utils/api.ts
+++ b/web/src/utils/api.ts
@@ -210,4 +210,47 @@ export default {
removeDataflow: `${api_host}/dataflow/rm`,
listDataflow: `${api_host}/dataflow/list`,
runDataflow: `${api_host}/dataflow/run`,
+
+ // admin
+ adminLogin: `${ExternalApi}${api_host}/admin/login`,
+ adminLogout: `${ExternalApi}${api_host}/admin/logout`,
+ adminListUsers: `${ExternalApi}${api_host}/admin/users`,
+ adminCreateUser: `${ExternalApi}${api_host}/admin/users`,
+ adminGetUserDetails: (username: string) =>
+ `${ExternalApi}${api_host}/admin/users/${username}`,
+ adminUpdateUserStatus: (username: string) =>
+ `${ExternalApi}${api_host}/admin/users/${username}/activate`,
+ adminUpdateUserPassword: (username: string) =>
+ `${ExternalApi}${api_host}/admin/users/${username}/password`,
+ adminDeleteUser: (username: string) =>
+ `${ExternalApi}${api_host}/admin/users/${username}`,
+ adminListUserDatasets: (username: string) =>
+ `${ExternalApi}${api_host}/admin/users/${username}/datasets`,
+ adminListUserAgents: (username: string) =>
+ `${ExternalApi}${api_host}/admin/users/${username}/agents`,
+
+ adminListServices: `${ExternalApi}${api_host}/admin/services`,
+ adminShowServiceDetails: (serviceId: string) =>
+ `${ExternalApi}${api_host}/admin/services/${serviceId}`,
+
+ adminListRoles: `${ExternalApi}${api_host}/admin/roles`,
+ adminListRolesWithPermission: `${ExternalApi}${api_host}/admin/roles_with_permission`,
+ adminGetRolePermissions: (roleName: string) =>
+ `${ExternalApi}${api_host}/admin/roles/${roleName}/permissions`,
+ adminAssignRolePermissions: (roleName: string) =>
+ `${ExternalApi}${api_host}/admin/roles/${roleName}/permissions`,
+ adminRevokeRolePermissions: (roleName: string) =>
+ `${ExternalApi}${api_host}/admin/roles/${roleName}/permissions/batch`,
+ adminCreateRole: `${ExternalApi}${api_host}/admin/roles`,
+ adminDeleteRole: (roleName: string) =>
+ `${ExternalApi}${api_host}/admin/roles/${roleName}`,
+ adminUpdateRoleDescription: (roleName: string) =>
+ `${ExternalApi}${api_host}/admin/roles/${roleName}`,
+
+ adminUpdateUserRole: (username: string) =>
+ `${ExternalApi}${api_host}/admin/users/${username}/role`,
+ adminGetUserPermissions: (username: string) =>
+ `${ExternalApi}${api_host}/admin/users/${username}/permissions`,
+
+ adminListResources: `${ExternalApi}${api_host}/admin/roles/resources`,
};
diff --git a/web/src/wrappers/authAdmin.tsx b/web/src/wrappers/authAdmin.tsx
new file mode 100644
index 000000000..46bf6fea4
--- /dev/null
+++ b/web/src/wrappers/authAdmin.tsx
@@ -0,0 +1,9 @@
+import { Routes } from '@/routes';
+import authorizationUtil from '@/utils/authorization-util';
+import { Navigate, Outlet } from 'umi';
+
+export default () => {
+ const isLogin = !!authorizationUtil.getAuthorization();
+
+ return isLogin ? : ;
+};