Feat: Admin UI (#10857)

### What problem does this PR solve?

Add admin UI for RAGFlow

### Type of change

- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
UN1C0DE
2025-10-28 22:25:43 +08:00
committed by GitHub
parent c3b0ab43e7
commit 2c0035dcea
25 changed files with 4442 additions and 9 deletions

View File

@@ -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/',

View File

@@ -39,7 +39,8 @@ const ScrollArea = React.forwardRef<
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollBar orientation="horizontal" />
<ScrollBar orientation="vertical" />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));

View File

@@ -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: `
<p>Deleting the generated <strong class='text-text-primary'>{{type}}</strong> results
will remove all derived entities and relationships from this dataset.
<p>Deleting the generated <strong class='text-text-primary'>{{type}}</strong> results
will remove all derived entities and relationships from this dataset.
Your original files will remain intact.<p>
<br/>
Do you want to continue?
@@ -1813,15 +1813,15 @@ Important structured information may include: names, dates, locations, events, k
</ul>`,
changeStepModalTitle: 'Step Switch Warning',
changeStepModalContent: `
<p>You are currently editing the results of this stage.</p>
<p>If you switch to a later stage, your changes will be lost. </p>
<p>You are currently editing the results of this stage.</p>
<p>If you switch to a later stage, your changes will be lost. </p>
<p>To keep them, please click Rerun to re-run the current stage.</p> `,
changeStepModalConfirmText: 'Switch Anyway',
changeStepModalCancelText: 'Cancel',
unlinkPipelineModalTitle: 'Unlink Ingestion pipeline',
unlinkPipelineModalContent: `
<p>Once unlinked, this Dataset will no longer be connected to the current Ingestion pipeline.</p>
<p>Files that are already being parsed will continue until completion</p>
<p>Once unlinked, this Dataset will no longer be connected to the current Ingestion pipeline.</p>
<p>Files that are already being parsed will continue until completion</p>
<p>Files that are not yet parsed will no longer be processed</p> <br/>
<p>Are you sure you want to proceed?</p> `,
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 <code>email</code>.',
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',
},
},
};

View File

@@ -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 (
<Result
status="404"
title="404"
subTitle="Page not found, please enter a correct address."
extra={
<Button type="primary" onClick={() => history.push('/')}>
<Button
type="primary"
onClick={() => {
history.push(
location.pathname.startsWith(Routes.Admin) ? Routes.Admin : '/',
);
}}
>
Business
</Button>
}

View File

@@ -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;
}

View File

@@ -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<typeof Root>,
React.ComponentPropsWithoutRef<typeof Root>
>(({ className, ...props }, ref) => {
const { setTheme } = useTheme();
const isDark = useIsDarkTheme();
return (
<Root
ref={ref}
className={cn('relative rounded-full')}
{...props}
checked={isDark}
onCheckedChange={(value) =>
setTheme(value ? ThemeEnum.Dark : ThemeEnum.Light)
}
>
<div className="px-3 py-2 rounded-full border border-border-button bg-bg-card transition-[background-color] duration-200">
<div className="flex items-center justify-between gap-4 relative z-[1] text-text-disabled transition-[text-color] duration-200">
<LucideSun
className={cn('size-[1em]', !isDark && 'text-text-primary')}
/>
<LucideMoon
className={cn('size-[1em]', isDark && 'text-text-primary')}
/>
</div>
</div>
<Thumb
className={cn(
'absolute top-0 left-0 w-[calc(50%+.25rem)] h-full rounded-full bg-bg-base border border-border-button',
'transition-all duration-200',
{ 'left-[calc(50%-.25rem)]': isDark },
)}
/>
</Root>
);
});
export default ThemeSwitch;

View File

@@ -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<typeof useForm<ChangePasswordFormData>>;
email?: string;
onSubmit?: (data: ChangePasswordFormData) => void;
}
export const ChangePasswordForm = ({
id,
form,
email,
onSubmit = () => {},
}: ChangePasswordFormProps) => {
const { t } = useTranslation();
return (
<Form {...form}>
<form
id={id}
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
>
{/* Email field (readonly) */}
<div>
<FormLabel className="text-sm font-medium">
{t('admin.email')}
</FormLabel>
<Input
value={email}
readOnly
className="mt-2 px-3 h-10 bg-bg-input border-border-button"
/>
</div>
{/* New password field */}
<FormField
control={form.control}
name="newPassword"
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium">
{t('admin.newPassword')}
</FormLabel>
<FormControl>
<Input
type="password"
placeholder={t('admin.newPassword')}
autoComplete="new-password"
className="mt-2 px-3 h-10 bg-bg-input border-border-button"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Confirm password field */}
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium">
{t('admin.confirmNewPassword')}
</FormLabel>
<FormControl>
<Input
type="password"
placeholder={t('admin.confirmNewPassword')}
autoComplete="new-password"
className="mt-2 px-3 h-10 bg-bg-input border-border-button"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
);
};
// 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<ChangePasswordFormData>({
defaultValues: {
newPassword: '',
confirmPassword: '',
},
resolver: zodResolver(schema),
});
const FormComponent = useCallback(
(props: Partial<ChangePasswordFormProps>) => (
<ChangePasswordForm id={id} form={form} {...props} />
),
[id, form],
);
return {
schema,
id,
form,
FormComponent,
};
}
export default useChangePasswordForm;

View File

@@ -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<typeof useForm<CreateEmailFormData>>;
onSubmit?: (data: CreateEmailFormData) => void;
}
export const CreateEmailForm = ({
id,
form,
onSubmit = () => {},
}: CreateEmailFormProps) => {
const { t } = useTranslation();
return (
<Form {...form}>
<form
id={id}
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
>
{/* Email field */}
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium">
{t('admin.email')}
</FormLabel>
<FormControl>
<Input
placeholder="name@example.com"
autoComplete="email"
className="mt-2 px-3 h-10 bg-bg-input border-border-button"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
);
};
// Export the form validation state for parent component
function useCreateEmailForm(props?: {
defaultValues: Partial<CreateEmailFormData>;
}) {
const { t } = useTranslation();
const id = useId();
const schema = useMemo(() => {
return z.object({
email: z.string().email({ message: t('admin.invalidEmail') }),
});
}, [t]);
const form = useForm<CreateEmailFormData>({
defaultValues: {
email: '',
...(props?.defaultValues ?? {}),
},
resolver: zodResolver(schema),
});
const FormComponent = useCallback(
(props: Partial<CreateEmailFormProps>) => (
<CreateEmailForm id={id} form={form} {...props} />
),
[id, form],
);
return {
schema,
id,
form,
FormComponent,
};
}
export default useCreateEmailForm;

View File

@@ -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<typeof useForm<ImportExcelFormData>>;
onSubmit?: (data: ImportExcelFormData) => void;
}
export const ImportExcelForm = ({
id,
form,
onSubmit = () => {},
}: ImportExcelFormProps) => {
const { t } = useTranslation();
return (
<Form {...form}>
<form
id={id}
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
>
{/* File input field */}
<FormField
control={form.control}
name="file"
render={({ field: { onChange, value, ...field } }) => (
<FormItem>
<FormLabel className="text-sm font-medium">
{t('admin.importSelectExcelFile')}
</FormLabel>
<FormControl>
<Input
type="file"
accept=".xlsx"
className="mt-2 px-3 h-10 bg-bg-input border-border-button file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-bg-accent file:text-text-primary hover:file:bg-bg-accent/80"
onChange={(e) => {
const files = e.target.files;
onChange(files);
}}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Overwrite checkbox */}
<FormField
control={form.control}
name="overwriteExisting"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2 text-sm font-medium">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
{t('admin.importOverwriteExistingEmails')}
</FormLabel>
</FormItem>
)}
/>
<p className="text-xs text-text-secondary">
<Trans
i18nKey="admin.importFileTips"
components={{ code: <code /> }}
/>
</p>
</form>
</Form>
);
};
// 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<ImportExcelFormData>({
defaultValues: {
file: undefined,
overwriteExisting: false,
},
resolver: zodResolver(schema),
});
const FormComponent = useCallback(
(props: Partial<ImportExcelFormProps>) => (
<ImportExcelForm id={id} form={form} {...props} />
),
[id, form],
);
return {
schema,
id,
form,
FormComponent,
};
}
export default useImportExcelForm;

View File

@@ -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<string, AdminService.PermissionData>;
}
interface CreateRoleFormProps {
id: string;
form: ReturnType<typeof useForm<CreateRoleFormData>>;
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 (
<Form {...form}>
<form
id={id}
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
>
{/* Role name field */}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium" required>
{t('admin.roleName')}
</FormLabel>
<FormControl>
<Input
placeholder={t('admin.roleName')}
className="mt-2 px-3 h-10 bg-bg-input border-border-button"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Role description field */}
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium">
{t('admin.description')}
</FormLabel>
<FormControl>
<Input
placeholder={t('admin.description')}
className="mt-2 px-3 h-10 bg-bg-input border-border-button"
{...field}
/>
</FormControl>
</FormItem>
)}
/>
{/* Permissions section */}
<div>
<Label>{t('admin.resources')}</Label>
<Tabs defaultValue={resourceTypes?.[0]} className="w-full mt-2">
<TabsList className="p-0 mb-2 gap-4 bg-transparent">
{resourceTypes?.map((resourceType) => (
<TabsTrigger
key={resourceType}
value={resourceType}
className="text-text-secondary border-border-button dark:data-[state=active]:bg-bg-input"
>
{t(`admin.resourceType.${resourceType}`)}
</TabsTrigger>
))}
</TabsList>
{resourceTypes?.map((resourceType) => (
<TabsContent
key={resourceType}
value={resourceType}
className="space-y-4"
>
<Card className="border-0 bg-bg-card">
<CardContent className="p-6">
<div className="grid grid-cols-4 gap-4">
{PERMISSION_TYPES.map((permissionType) => (
<FormField
key={permissionType}
name={`permissions.${resourceType}.${permissionType}`}
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
{t(`admin.permissionType.${permissionType}`)}
</FormLabel>
<FormControl>
<Switch
{...field}
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
))}
</div>
</CardContent>
</Card>
</TabsContent>
))}
</Tabs>
</div>
</form>
</Form>
);
};
// Export the form validation state for parent component
function useCreateRoleForm(props?: {
defaultValues: Partial<CreateRoleFormData>;
}) {
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<CreateRoleFormData>({
defaultValues: {
name: '',
description: '',
permissions: {},
...(props?.defaultValues ?? {}),
},
resolver: zodResolver(schema),
});
const FormComponent = useCallback(
(props: Partial<CreateRoleFormProps>) => (
<CreateRoleForm id="create-role-form" form={form} {...props} />
),
[form],
);
return {
schema,
id,
form,
FormComponent,
};
}
export default useCreateRoleForm;

View File

@@ -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<typeof useForm<CreateUserFormData>>;
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 (
<Form {...form}>
<form
id={id}
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
>
{/* Email field (editable) */}
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium">
{t('admin.email')}
</FormLabel>
<FormControl>
<Input
placeholder={t('admin.email')}
autoComplete="username"
className="mt-2 px-3 h-10 bg-bg-input border-border-button"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Password field */}
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium">
{t('admin.password')}
</FormLabel>
<FormControl>
<Input
type="password"
placeholder={t('admin.password')}
autoComplete="new-password"
className="mt-2 px-3 h-10 bg-bg-input border-border-button"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Confirm password field */}
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium">
{t('admin.confirmPassword')}
</FormLabel>
<FormControl>
<Input
type="password"
placeholder={t('admin.confirmPassword')}
autoComplete="new-password"
className="mt-2 px-3 h-10 bg-bg-input border-border-button"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<EnterpriseFeature>
{/* Role field */}
{() => (
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium">
{t('admin.role')}
</FormLabel>
<FormControl>
<Select {...field}>
<SelectTrigger className="w-full h-10">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-bg-base">
<SelectGroup>
{roleList?.map((role) => (
<SelectItem key={role.id} value={role.role_name}>
{role.role_name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
)}
</EnterpriseFeature>
</form>
</Form>
);
};
// Export the form validation state for parent component
function useCreateUserForm(props?: {
defaultValues: Partial<CreateUserFormData>;
}) {
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<CreateUserFormData>({
defaultValues: {
email: '',
password: '',
confirmPassword: '',
...(props?.defaultValues ?? {}),
},
resolver: zodResolver(schema),
});
const FormComponent = useCallback(
(props: Partial<CreateUserFormProps>) => (
<CreateUserForm id={id} form={form} {...props} />
),
[id, form],
);
return {
schema,
id,
form,
FormComponent,
};
}
export default useCreateUserForm;

View File

@@ -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<z.infer<typeof FormSchema>> = 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 (
<div className="relative w-screen h-screen">
<Spotlight opcity={0.4} coverage={60} color="rgb(128, 255, 248)" />
<Spotlight
opcity={0.3}
coverage={12}
X="10%"
Y="-10%"
color="rgb(128, 255, 248)"
/>
<Spotlight
opcity={0.3}
coverage={12}
X="90%"
Y="-10%"
color="rgb(128, 255, 248)"
/>
<BgSvg />
<div className="absolute top-3 left-0 w-full">
<div className="absolute mt-12 ml-12 flex items-center">
<img className="size-8 mr-5" src="/logo.svg" alt="logo" />
<span className="text-xl font-bold">RAGFlow</span>
</div>
<h1 className="mt-[6.5rem] text-4xl font-medium text-center mb-12">
{t('loginTitle', { keyPrefix: 'admin' })}
</h1>
</div>
<div className="flex items-center justify-center w-screen h-screen">
<div className="w-full max-w-[540px]">
<Card className="w-full bg-bg-component backdrop-blur-sm rounded-2xl border border-border-button">
<CardContent className="px-10 pt-14 pb-10">
<Form {...form}>
<form
id={formId}
className="space-y-8 text-text-primary"
onSubmit={form.handleSubmit(onCheck)}
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel required>{t('emailLabel')}</FormLabel>
<FormControl>
<Input
className="h-10 px-2.5"
placeholder={t('emailPlaceholder')}
autoComplete="email"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel required>{t('passwordLabel')}</FormLabel>
<FormControl>
<div className="relative">
<Input
className="h-10 px-2.5"
type={showPassword ? 'text' : 'password'}
placeholder={t('passwordPlaceholder')}
autoComplete="password"
{...field}
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<LucideEyeOff className="h-4 w-4 text-gray-500" />
) : (
<LucideEye className="h-4 w-4 text-gray-500" />
)}
</button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="remember"
render={({ field }) => (
<FormItem className="!mt-5">
<FormLabel
className={cn(
'flex items-center hover:text-text-primary',
field.value
? 'text-text-primary'
: 'text-text-disabled',
)}
>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<span className="ml-2">{t('rememberMe')}</span>
</FormLabel>
</FormItem>
)}
/>
</form>
</Form>
</CardContent>
<CardFooter className="px-10 pt-8 pb-14">
<ButtonLoading
form={formId}
size="lg"
className="
w-full h-10
bg-metallic-gradient border-b-[#00BEB4] border-b-2
hover:bg-metallic-gradient hover:border-b-[#02bcdd]
"
type="submit"
loading={loading}
>
{t('login')}
</ButtonLoading>
</CardFooter>
</Card>
<div className="mt-8 flex justify-center">
<ThemeSwitch />
</div>
</div>
</div>
</div>
);
}
export default AdminLogin;

View File

@@ -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: <LucideServerCrash className="size-[1em]" />,
},
{
path: Routes.AdminUserManagement,
name: t('admin.userManagement'),
icon: <LucideUserCog className="size-[1em]" />,
},
...(IS_ENTERPRISE
? [
{
path: Routes.AdminWhitelist,
name: t('admin.registrationWhitelist'),
icon: <LucideUserStar className="size-[1em]" />,
},
{
path: Routes.AdminRoles,
name: t('admin.roles'),
icon: <LucideSquareUserRound className="size-[1em]" />,
},
{
path: Routes.AdminMonitoring,
name: t('admin.monitoring'),
icon: <LucideMonitor className="size-[1em]" />,
},
]
: []),
],
[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 (
<main className="w-screen h-screen flex flex-row px-6 pt-12 pb-6 dark:*:focus-visible:ring-white">
<aside className="w-[28rem] mr-6 flex flex-col gap-6">
<div className="flex items-center mb-6">
<img className="size-8 mr-5" src="/logo.svg" alt="logo" />
<span className="text-xl font-bold">{t('admin.title')}</span>
</div>
<nav>
<ul className="space-y-4">
{navItems.map((it) => (
<li key={it.path}>
<NavLink
to={it.path}
className={cn(
'px-4 py-3 rounded-lg',
'text-base w-full flex items-center justify-start text-text-secondary',
'hover:bg-bg-card focus:bg-bg-card focus-visible:bg-bg-card',
'hover:text-text-primary focus:text-text-primary focus-visible:text-text-primary',
'active:text-text-primary',
{
'bg-bg-card text-text-primary':
it.path && pathname.startsWith(it.path),
},
)}
>
{it.icon}
<span className="ml-3">{it.name}</span>
</NavLink>
</li>
))}
</ul>
</nav>
<div className="mt-auto space-y-4">
<div className="text-right">
<ThemeSwitch />
</div>
<Button
size="lg"
variant="transparent"
className="block w-full dark:border-border-button"
onClick={() => logout()}
>
{t('header.logout')}
</Button>
</div>
</aside>
<section className="w-full h-full">
<Outlet />
</section>
</main>
);
};
export default AdminLayout;

View File

@@ -0,0 +1,13 @@
import { Card, CardContent } from '@/components/ui/card';
function AdminMonitoring() {
return (
<Card className="h-full border border-border-button bg-transparent rounded-xl overflow-x-hidden overflow-y-auto">
<CardContent className="size-full p-0">
<iframe />
</CardContent>
</Card>
);
}
export default AdminMonitoring;

View File

@@ -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<T extends unknown>(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 (
<>
<Card className="h-full border border-border-button bg-transparent rounded-xl">
<ScrollArea className="size-full">
<CardHeader className="space-y-0 flex flex-row justify-between items-center">
<CardTitle>{t('admin.roles')}</CardTitle>
<Button
className="h-10 px-4"
onClick={() => setIsAddRoleModalOpen(true)}
>
<LucideUserPlus />
{t('admin.newRole')}
</Button>
</CardHeader>
<CardContent className="space-y-6">
{roleList?.map((role) => {
const resources = Object.entries(role.permissions);
return (
<Card
key={role.id}
className="group border border-border-default bg-transparent hover:bg-bg-card transition-color duration-150"
>
<CardHeader className="space-y-0 flex flex-row items-center border-b border-border-button">
<div className="space-y-1.5">
<CardTitle className="font-normal text-xl">
{role.role_name}
</CardTitle>
<div className="text-sm text-text-secondary">
{role.description || (
<i className="text-muted-foreground">
{t('admin.noDescription')}
</i>
)}
<Button
variant="transparent"
className="ml-2 p-0 border-0 size-[1em] align-middle opacity-0 group-hover:opacity-100 group-focus-within:opacity-100"
>
<LucideEdit3 className="!size-[1em]" />
</Button>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="ml-auto opacity-0 group-hover:opacity-100 group-focus-within:opacity-100"
>
<LucideTrash2 />
</Button>
</CardHeader>
<CardContent className="p-6">
<Tabs
className="h-full flex flex-col"
defaultValue={resources[0]?.[0]}
>
<TabsList className="p-0 mb-2 gap-4 bg-transparent">
{resources.map(([name]) => (
<TabsTrigger
key={name}
value={name}
className="border-border-button dark:data-[state=active]:bg-bg-input"
>
{t(`admin.resourceType.${name}`)}
</TabsTrigger>
))}
</TabsList>
{resources.map(([name, permission]) => (
<TabsContent key={name} value={name}>
<div className="flex gap-8">
<Label className="flex items-center gap-2">
<Switch
checked={!!permission.enable}
onCheckedChange={console.log}
/>
{t('admin.enable')}
</Label>
<Label className="flex items-center gap-2">
<Switch
checked={!!permission.read}
onCheckedChange={() => {}}
/>
{t('admin.read')}
</Label>
<Label className="flex items-center gap-2">
<Switch
checked={!!permission.write}
onCheckedChange={() => {}}
/>
{t('admin.write')}
</Label>
<Label className="flex items-center gap-2">
<Switch
checked={!!permission.share}
onCheckedChange={() => {}}
/>
{t('admin.share')}
</Label>
</div>
</TabsContent>
))}
</Tabs>
</CardContent>
</Card>
);
})}
</CardContent>
</ScrollArea>
</Card>
{/* Add Role Modal */}
<Dialog open={isAddRoleModalOpen} onOpenChange={setIsAddRoleModalOpen}>
<DialogContent className="max-w-2xl p-0 border-border-button">
<DialogHeader className="p-6 border-b border-border-button">
<DialogTitle>{t('admin.addNewRole')}</DialogTitle>
</DialogHeader>
<section className="px-12 py-4">
<createRoleForm.FormComponent onSubmit={handleAddRole} />
</section>
<DialogFooter className="flex justify-end gap-4 px-12 pt-4 pb-8">
<Button
className="px-4 h-10 dark:border-border-button"
variant="outline"
onClick={() => setIsAddRoleModalOpen(false)}
>
{t('admin.cancel')}
</Button>
<LoadingButton
type="submit"
form={createRoleForm.id}
className="px-4 h-10"
variant="default"
>
{t('admin.confirm')}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
export default AdminRoles;

View File

@@ -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 (
<Table rootClassName="min-w-max">
<TableHeader>
<TableRow>
{headers.map((header) => (
<TableHead key={header}>{header}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{content.map((item) => (
<TableRow key={item.id as string}>
{headers.map((header: string) => (
<TableCell key={header}>{item[header] as string}</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
);
}
if (isPlainObject(content)) {
return (
<dl className="grid grid-cols-[auto,1fr] border border-card rounded-xl overflow-hidden bg-bg-card">
{Object.entries<any>(content).map(([key, value]) => (
<div key={key} className="contents">
<dt className="px-3 py-2 bg-bg-card">
<pre>
<code>{key}</code>
</pre>
</dt>
<dd className="px-3 py-2">
<pre>
<code>{JSON.stringify(value)}</code>
</pre>
</dd>
</div>
))}
</dl>
);
}
if (typeof content === 'string') {
return (
<div className="rounded-lg p-4 border border-border-button bg-bg-input">
<pre className="text-sm">
<code>
{typeof content === 'string'
? content
: JSON.stringify(content, null, 2)}
</code>
</pre>
</div>
);
}
return content;
}, [content]);
return contentElement;
}
export default ServiceDetail;

View File

@@ -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<AdminService.ListServicesItem>();
const globalFilterFn = createFuzzySearchFn<AdminService.ListServicesItem>([
'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<AdminService.ListServicesItem | null>(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 }) => (
<Badge variant="secondary" className="font-normal text-text-primary">
<i>{row.getValue('host')}</i>
</Badge>
),
}),
columnHelper.accessor('port', {
header: t('admin.port'),
cell: ({ row }) => (
<Badge variant="secondary" className="font-normal text-text-primary">
<i>{row.getValue('port')}</i>
</Badge>
),
}),
columnHelper.accessor('status', {
header: t('admin.status'),
cell: ({ cell }) => (
<Badge
variant="secondary"
className={cn(
'pl-2 font-normal text-sm text-text-primary capitalize',
{
alive: 'bg-state-success-5 text-state-success',
timeout: 'bg-state-error-5 text-state-error',
fail: 'bg-gray-500/5 text-text-disable',
}[cell.getValue<string>()],
)}
>
<LucideDot className="size-[1em] stroke-[8] mr-1" />
{cell.getValue()}
</Badge>
),
enableSorting: false,
}),
columnHelper.display({
id: 'actions',
header: t('admin.actions'),
cell: ({ row }) => (
<div className="opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity duration-100">
<Button
variant="transparent"
size="icon"
className="border-0 text-text-secondary"
onClick={() => {
setItemToMakeAction(row.original);
setExtraInfoModalOpen(true);
}}
>
<LucideSettings2 />
</Button>
<Button
variant="transparent"
size="icon"
className="border-0 text-text-secondary"
onClick={() => {
setItemToMakeAction(row.original);
setDetailModalOpen(true);
}}
>
<LucideClipboardList />
</Button>
</div>
),
}),
],
[],
);
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 (
<>
<Card className="h-full border border-border-button bg-transparent rounded-xl">
<ScrollArea className="size-full">
<CardHeader className="space-y-0 flex flex-row justify-between items-center">
<CardTitle>{t('admin.serviceStatus')}</CardTitle>
<div className="flex items-center gap-4">
<Popover>
<PopoverTrigger asChild>
<Button
size="icon"
variant="outline"
className="dark:bg-bg-input dark:border-border-button text-text-secondary"
>
<LucideFilter className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
className="bg-bg-base text-text-secondary"
>
<div className="p-2 space-y-6">
<section>
<div className="font-bold mb-3">
{t('admin.serviceType')}
</div>
<RadioGroup
value={
(getColumnFilter(table, 'service_type')
?.value as string) ?? ''
}
onValueChange={(value) =>
setColumnFilter(table, 'service_type', value)
}
>
<Label className="space-x-2">
<RadioGroupItem
className="bg-bg-input border-border-button"
value=""
/>
<span>{t('admin.all')}</span>
</Label>
{SERVICE_TYPE_FILTER_OPTIONS.map(({ label, value }) => (
<Label key={value} className="space-x-2">
<RadioGroupItem
className="bg-bg-input border-border-button"
value={value}
/>
<span>{label}</span>
</Label>
))}
</RadioGroup>
</section>
</div>
<div className="pt-4 flex justify-end">
<Button
variant="outline"
className="dark:bg-bg-input dark:border-border-button text-text-secondary"
onClick={() => table.resetColumnFilters()}
>
{t('admin.reset')}
</Button>
</div>
</PopoverContent>
</Popover>
<div className="relative w-56">
<LucideSearch className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
className="pl-10 h-10 bg-bg-input border-border-button"
placeholder={t('header.search')}
value={table.getState().globalFilter}
onChange={(e) => table.setGlobalFilter(e.target.value)}
/>
</div>
</div>
</CardHeader>
<CardContent>
<Table>
<colgroup>
<col className="w-[6%]" />
<col />
<col className="w-[22%]" />
<col className="w-[13%]" />
<col className="w-[10%]" />
<col className="w-[10%]" />
<col className="w-52" />
</colgroup>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder ? null : header.column.getCanSort() ? (
<Button
variant="ghost"
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
{getSortIcon(header.column.getIsSorted())}
</Button>
) : (
flexRender(
header.column.columnDef.header,
header.getContext(),
)
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} className="group">
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableEmpty columnsLength={columnDefs.length} />
)}
</TableBody>
</Table>
</CardContent>
<CardFooter className="flex items-center justify-end">
<RAGFlowPagination
total={servicesList?.length}
current={table.getState().pagination.pageIndex + 1}
pageSize={table.getState().pagination.pageSize}
onChange={(page, pageSize) => {
table.setPagination({
pageIndex: page - 1,
pageSize,
});
}}
/>
</CardFooter>
</ScrollArea>
</Card>
{/* Extra info modal*/}
<Dialog open={extraInfoModalOpen} onOpenChange={setExtraInfoModalOpen}>
<DialogContent
className="p-0 border-border-button"
onAnimationEnd={() => {
if (!extraInfoModalOpen) {
setItemToMakeAction(null);
}
}}
>
<DialogHeader className="p-6 border-b border-border-button">
<DialogTitle>{t('admin.extraInfo')}</DialogTitle>
</DialogHeader>
<section className="px-12 pt-6 pb-4">
<div className="rounded-lg p-4 border border-border-button bg-bg-input">
<pre className="text-sm">
<code>
{JSON.stringify(itemToMakeAction?.extra ?? {}, null, 2)}
</code>
</pre>
</div>
</section>
<DialogFooter className="flex justify-end gap-4 px-12 pt-4 pb-8">
<Button
className="px-4 h-10 dark:border-border-button"
variant="outline"
onClick={() => setExtraInfoModalOpen(false)}
>
{t('admin.close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Service details modal */}
<Dialog open={detailModalOpen} onOpenChange={setDetailModalOpen}>
<DialogContent
className="flex flex-col max-h-[calc(100vh-4rem)] max-w-6xl p-0 border-border-button"
onAnimationEnd={() => {
if (!detailModalOpen) {
setItemToMakeAction(null);
}
}}
>
<DialogHeader className="p-6 border-b border-border-button">
<DialogTitle>
<Trans i18nKey="admin.serviceDetail">
{{ name: itemToMakeAction?.name }}
</Trans>
</DialogTitle>
</DialogHeader>
<ScrollArea className="pt-6 pb-4 px-12 h-0 flex-1 text-text-secondary flex flex-col">
<ServiceDetail content={serviceDetails?.message} />
</ScrollArea>
<DialogFooter className="flex justify-end gap-4 px-12 pt-4 pb-8">
<Button
className="px-4 h-10 dark:border-border-button"
variant="outline"
onClick={() => {
setDetailModalOpen(false);
}}
>
{t('admin.close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
export default AdminServiceStatus;

View File

@@ -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<AdminService.ListUserDatasetItem>();
const agentColumnHelper = createColumnHelper<AdminService.ListUserAgentItem>();
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 (
<Badge
variant="secondary"
className={cn(
'font-normal text-sm pl-2',
parseBooleanish(cell.getValue())
? 'bg-state-success-5 text-state-success'
: 'bg-state-error-5 text-state-error',
)}
>
<LucideDot className="size-[1em] stroke-[8] mr-1" />
{t(
parseBooleanish(cell.getValue())
? 'admin.active'
: 'admin.inactive',
)}
</Badge>
);
},
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 (
<section className="space-y-4">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder ? null : header.column.getCanSort() ? (
<Button
variant="ghost"
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
{getSortIcon(header.column.getIsSorted())}
</Button>
) : (
flexRender(
header.column.columnDef.header,
header.getContext(),
)
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={table.getAllColumns().length}
className="h-24 text-center"
>
{t('common.noData')}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<RAGFlowPagination
total={props.data?.length}
current={table.getState().pagination.pageIndex + 1}
pageSize={table.getState().pagination.pageSize}
onChange={(page, pageSize) => {
table.setPagination({
pageIndex: page - 1,
pageSize,
});
}}
/>
</section>
);
}
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 (
<section className="space-y-4">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder ? null : (
<>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
{/* {header.column.getCanFilter() && (
<Button
variant="ghost"
>
<LucideFilter />
</Button>
)} */}
</>
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow key="empty">
<TableCell
colSpan={table.getAllColumns().length}
className="h-24 text-center"
>
{t('common.noData')}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<RAGFlowPagination
total={props.data?.length}
current={table.getState().pagination.pageIndex + 1}
pageSize={table.getState().pagination.pageSize}
onChange={(page, pageSize) => {
table.setPagination({
pageIndex: page - 1,
pageSize,
});
}}
/>
</section>
);
}
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 (
<section className="px-10 py-5 size-full flex flex-col">
<nav className="mb-5">
<Button
variant="outline"
className="h-10 px-3 dark:bg-bg-input dark:border-border-button"
onClick={() => navigate(`${Routes.AdminUserManagement}`)}
>
<LucideArrowLeft />
<span>{t('admin.userManagement')}</span>
</Button>
</nav>
<Card className="h-0 basis-0 grow flex flex-col bg-transparent border dark:border-border-button overflow-hidden">
<CardHeader className="pb-10 border-b dark:border-border-button space-y-8">
<section className="flex items-center gap-4 text-base">
<Avatar className="justify-center items-center bg-bg-group uppercase">
{detail?.email
.split('@')[0]
.replace(/[^0-9a-z]/gi, '')
.slice(0, 2) || <LucideUser2 />}
</Avatar>
<span>{detail?.email}</span>
<Badge
variant="secondary"
className={cn(
'font-normal text-sm pl-2',
parseBooleanish(detail?.is_active)
? 'bg-state-success-5 text-state-success'
: '',
)}
>
<LucideDot className="size-[1em] stroke-[8] mr-1" />
{t(
parseBooleanish(detail?.is_active)
? 'admin.active'
: 'admin.inactive',
)}
</Badge>
<EnterpriseFeature>
{() => (
<Badge variant="secondary" className="font-normal text-sm">
{detail?.role}
</Badge>
)}
</EnterpriseFeature>
</section>
<section className="flex items-start px-14 space-x-14">
<div>
<div className="text-sm text-text-secondary mb-2">
{t('admin.lastLoginTime')}
</div>
<div>{detail?.last_login_time}</div>
</div>
<div>
<div className="text-sm text-text-secondary mb-2">
{t('admin.createTime')}
</div>
<div>{detail?.create_date}</div>
</div>
<div>
<div className="text-sm text-text-secondary mb-2">
{t('admin.lastUpdateTime')}
</div>
<div>{detail?.update_date}</div>
</div>
<div>
<div className="text-sm text-text-secondary mb-2">
{t('admin.language')}
</div>
<div>{detail?.language}</div>
</div>
<div>
<div className="text-sm text-text-secondary mb-2">
{t('admin.isAnonymous')}
</div>
<div>{t(detail?.is_anonymous ? 'admin.yes' : 'admin.no')}</div>
</div>
</section>
</CardHeader>
<CardContent className="h-0 basis-0 grow pt-6">
<Tabs className="h-full flex flex-col" defaultValue="dataset">
<TabsList className="p-0 mb-2 gap-4 bg-transparent">
{ASSET_NAMES.map((name) => (
<TabsTrigger
key={name}
className="border-border-button data-[state=active]:bg-bg-card"
value={name}
>
{t(`header.${name}`)}
</TabsTrigger>
))}
</TabsList>
<TabsContent value="dataset" className="h-0 basis-0 grow">
<ScrollArea className="h-full">
<UserDatasetTable data={datasets} />
</ScrollArea>
</TabsContent>
<TabsContent value="flow" className="h-0 basis-0 grow">
<ScrollArea className="h-full">
<UserAgentTable data={agents} />
</ScrollArea>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</section>
);
}
export default AdminUserDetail;

View File

@@ -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<AdminService.ListUsersItem>();
const globalFilterFn = createFuzzySearchFn<AdminService.ListUsersItem>([
'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<AdminService.ListUsersItem | null>(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 }) => (
<Select
value={cell.getValue()}
onValueChange={(value) => {
if (!updateUserRoleMutation.isPending) {
updateUserRoleMutation.mutate({
email: row.original.email,
role: value,
});
}
}}
disabled={updateUserRoleMutation.isPending}
>
<SelectTrigger className="h-10">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-bg-base">
{roleList?.map(({ id, role_name }) => (
<SelectItem key={id} value={role_name}>
{role_name}
</SelectItem>
))}
</SelectContent>
</Select>
),
filterFn: createColumnFilterFn(
(row, id, filterValue) => row.getValue(id) === filterValue,
{
autoRemove: (v) => !v,
},
),
}),
]
: []),
columnHelper.display({
id: 'enable',
header: t('admin.enable'),
cell: ({ row }) => (
<Switch
checked={parseBooleanish(row.original.is_active)}
onCheckedChange={(checked) => {
updateUserStatusMutation.mutate({
email: row.original.email,
isActive: checked,
});
}}
disabled={updateUserStatusMutation.isPending}
/>
),
}),
columnHelper.accessor('is_active', {
header: t('admin.status'),
cell: ({ cell }) => (
<Badge
variant="secondary"
className={cn(
'pl-2 font-normal text-sm',
parseBooleanish(cell.getValue())
? 'bg-state-success-5 text-state-success'
: '',
)}
>
<LucideDot className="size-[1em] stroke-[8] mr-1" />
{t(
parseBooleanish(cell.getValue())
? 'admin.active'
: 'admin.inactive',
)}
</Badge>
),
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 }) => (
<div className="opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity duration-100">
<Button
variant="transparent"
size="icon"
className="border-0 text-text-secondary"
onClick={() =>
navigate(`${Routes.AdminUserManagement}/${row.original.email}`)
}
>
<LucideClipboardList />
</Button>
<Button
variant="transparent"
size="icon"
className="border-0 text-text-secondary"
onClick={() => {
setUserToMakeAction(row.original);
setPasswordModalOpen(true);
}}
>
<LucideUserLock />
</Button>
<Button
variant="transparent"
size="icon"
className="border-0 text-text-secondary"
onClick={() => {
setUserToMakeAction(row.original);
setDeleteModalOpen(true);
}}
>
<LucideTrash2 />
</Button>
</div>
),
}),
],
[
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 (
<>
<Card className="h-full border border-border-button bg-transparent rounded-xl overflow-x-hidden overflow-y-auto">
<ScrollArea className="size-full">
<CardHeader className="space-y-0 flex flex-row justify-between items-center">
<CardTitle>{t('admin.userManagement')}</CardTitle>
<div className="ml-auto flex justify-end gap-4">
<Popover>
<PopoverTrigger asChild>
<Button
size="icon"
variant="outline"
className="dark:bg-bg-input dark:border-border-button text-text-secondary"
>
<LucideFilter className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
className="bg-bg-base text-text-secondary"
>
<div className="p-2 space-y-6">
<EnterpriseFeature>
{() => (
<section>
<div className="font-bold mb-3">
{t('admin.role')}
</div>
<RadioGroup
value={
(table
.getColumn('role')
?.getFilterValue() as string) ?? ''
}
onValueChange={(value) =>
table.getColumn('role')?.setFilterValue(value)
}
>
<Label className="space-x-2">
<RadioGroupItem value="" />
<span>{t('admin.all')}</span>
</Label>
{roleList?.map(({ id, role_name }) => (
<Label key={id} className="space-x-2">
<RadioGroupItem
className="bg-bg-input border-border-button"
value={role_name}
/>
<span>{role_name}</span>
</Label>
))}
</RadioGroup>
</section>
)}
</EnterpriseFeature>
<section>
<div className="font-bold mb-3">{t('admin.status')}</div>
<RadioGroup
value={
(table
.getColumn('is_active')
?.getFilterValue() as string) ?? ''
}
onValueChange={(value) =>
table.getColumn('is_active')?.setFilterValue(value)
}
>
{STATUS_FILTER_OPTIONS.map(({ label, value }) => (
<Label key={value} className="space-x-2">
<RadioGroupItem
className="bg-bg-input border-border-button"
value={value}
/>
<span>{t(label)}</span>
</Label>
))}
</RadioGroup>
</section>
</div>
<div className="pt-4 flex justify-end">
<Button
variant="outline"
className="dark:bg-bg-input dark:border-border-button text-text-secondary"
onClick={() => table.resetColumnFilters()}
>
{t('admin.reset')}
</Button>
</div>
</PopoverContent>
</Popover>
<div className="relative w-56">
<LucideSearch className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
className="pl-10 h-10 bg-bg-input border-border-button"
placeholder={t('header.search')}
value={table.getState().globalFilter}
onChange={(e) => table.setGlobalFilter(e.target.value)}
/>
</div>
<Button
className="h-10 px-4"
onClick={() => setCreateUserModalOpen(true)}
>
<LucideUserPlus />
{t('admin.newUser')}
</Button>
</div>
</CardHeader>
<CardContent>
<Table>
<colgroup>
<col width="*" />
<col className="w-[22%]" />
<EnterpriseFeature>
{() => <col className="w-[12%]" />}
</EnterpriseFeature>
<col className="w-[10%]" />
<col className="w-[12%]" />
<col className="w-52" />
</colgroup>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} className="group">
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableEmpty key="empty" columnsLength={columnDefs.length} />
)}
</TableBody>
</Table>
</CardContent>
<CardFooter className="flex items-center justify-end">
<RAGFlowPagination
total={usersList?.length ?? 0}
current={table.getState().pagination.pageIndex + 1}
pageSize={table.getState().pagination.pageSize}
onChange={(page, pageSize) => {
table.setPagination({
pageIndex: page - 1,
pageSize,
});
}}
/>
</CardFooter>
</ScrollArea>
</Card>
{/* Delete Confirmation Modal */}
<Dialog open={deleteModalOpen} onOpenChange={setDeleteModalOpen}>
<DialogContent className="p-0 border-border-button">
<DialogHeader className="p-6 border-b border-border-button">
<DialogTitle>{t('admin.deleteUser')}</DialogTitle>
</DialogHeader>
<section className="px-12 py-4">
<DialogDescription className="text-text-primary">
{t('admin.deleteUserConfirmation')}
<div className="rounded-lg mt-6 p-4 border border-border-button">
{userToMakeAction?.email}
</div>
</DialogDescription>
</section>
<DialogFooter className="flex justify-end gap-4 px-12 pt-4 pb-8">
<Button
className="px-4 h-10 dark:border-border-button"
variant="outline"
onClick={() => setDeleteModalOpen(false)}
disabled={deleteUserMutation.isPending}
>
{t('admin.cancel')}
</Button>
<LoadingButton
className="px-4 h-10"
variant="destructive"
onClick={() =>
deleteUserMutation.mutate(userToMakeAction?.email || '')
}
disabled={deleteUserMutation.isPending}
loading={deleteUserMutation.isPending}
>
{t('admin.delete')}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Change Password Modal */}
<Dialog open={passwordModalOpen} onOpenChange={setPasswordModalOpen}>
<DialogContent className="p-0 border-border-button">
<DialogHeader className="p-6 border-b border-border-button">
<DialogTitle>{t('admin.changePassword')}</DialogTitle>
</DialogHeader>
<section className="px-12 py-4 text-text-secondary">
<changePasswordForm.FormComponent
key="changePasswordForm"
email={userToMakeAction?.email || ''}
onSubmit={({ newPassword }) => {
if (userToMakeAction) {
changePasswordMutation.mutate({
email: userToMakeAction.email,
password: rsaPsw(newPassword) as string,
});
}
}}
/>
</section>
<DialogFooter className="flex justify-end gap-4 px-12 pt-4 pb-8">
<Button
className="px-4 h-10 dark:border-border-button"
variant="outline"
onClick={() => {
setPasswordModalOpen(false);
setUserToMakeAction(null);
}}
disabled={changePasswordMutation.isPending}
>
{t('admin.cancel')}
</Button>
<LoadingButton
form={changePasswordForm.id}
className="px-4 h-10"
variant="default"
type="submit"
disabled={changePasswordMutation.isPending}
loading={changePasswordMutation.isPending}
>
{t('admin.changePassword')}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Create User Modal */}
<Dialog
open={createUserModalOpen}
onOpenChange={() => {
setCreateUserModalOpen(false);
createUserForm.form.reset();
}}
>
<DialogContent className="p-0 border-border-button">
<DialogHeader className="p-6 border-b border-border-button">
<DialogTitle>{t('admin.createNewUser')}</DialogTitle>
</DialogHeader>
<section className="px-12 py-4">
<createUserForm.FormComponent
id={createUserForm.id}
onSubmit={({ email, password }) => {
createUserMutation.mutate({
email: email,
password: rsaPsw(password) as string,
});
}}
/>
</section>
<DialogFooter className="flex justify-end gap-4 px-12 pt-4 pb-8">
<Button
className="px-4 h-10 dark:border-border-button"
variant="outline"
onClick={() => {
setCreateUserModalOpen(false);
createUserForm.form.reset();
}}
disabled={createUserMutation.isPending}
>
{t('admin.cancel')}
</Button>
<LoadingButton
form={createUserForm.id}
type="submit"
className="px-4 h-10"
variant="default"
disabled={createUserMutation.isPending}
loading={createUserMutation.isPending}
>
{t('admin.confirm')}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
export default AdminUserManagement;

View File

@@ -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<TData extends RowData>(
columns: (keyof TData)[] = [],
) {
return (row: Row<TData>, columnId: string, filterValue: string) => {
const searchText = filterValue.trim().toLowerCase();
return columns
.map((n) =>
row
.getValue<string>(n as string)
.trim()
.toLowerCase(),
)
.some((v) => v.includes(searchText));
};
}
export function createColumnFilterFn<TData extends RowData>(
filterFn: FilterFn<TData>,
options: {
resolveFilterValue?: TransformFilterValueFn<TData>;
autoRemove?: ColumnFilterAutoRemoveTestFn<TData>;
},
) {
return Object.assign(filterFn, options) as FilterFn<TData>;
}
export function getColumnFilter<TData extends RowData>(
table: Table<TData>,
columnId: string,
) {
return table
.getState()
.columnFilters.find((filter) => filter.id === columnId);
}
export function setColumnFilter<TData extends RowData>(
table: Table<TData>,
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: <LucideSortAsc />,
desc: <LucideSortDesc />,
}[sorting as string];
}
export const EMPTY_DATA = Object.freeze<any[]>([]) as any[];
export const IS_ENTERPRISE =
process.env.UMI_APP_RAGFLOW_ENTERPRISE === 'RAGFLOW_ENTERPRISE';

View File

@@ -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<T extends unknown>(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<string | null>(
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 }) => (
<div className="opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity duration-100">
<Button
variant="transparent"
size="icon"
className="border-0 text-text-secondary"
onClick={() => {
setEmailToMakeAction(row.original.email);
setEditModalOpen(true);
}}
>
<LucideUserPen />
</Button>
<Button
variant="transparent"
size="icon"
className="border-0 text-text-secondary"
onClick={() => {
setEmailToMakeAction(row.original.email);
setDeleteModalOpen(true);
}}
>
<LucideTrash2 />
</Button>
</div>
),
}),
],
[],
);
const table = useReactTable({
data: PSEUDO_TABLE_ITEMS ?? EMPTY_DATA,
columns: columnDefs,
globalFilterFn,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
<>
<Card className="h-full border border-border-button bg-transparent rounded-xl overflow-x-hidden overflow-y-auto">
<ScrollArea className="size-full">
<CardHeader className="space-y-0 flex flex-row justify-between items-center">
<CardTitle>{t('admin.whitelistManagement')}</CardTitle>
<div className="flex items-center gap-4">
<div className="relative w-56">
<LucideSearch className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
className="pl-10 h-10 bg-bg-input border-border-button"
placeholder={t('header.search')}
value={table.getState().globalFilter}
onChange={(e) => table.setGlobalFilter(e.target.value)}
/>
</div>
<Button
variant="outline"
className="h-10 px-4 dark:bg-bg-input dark:border-border-button text-text-secondary"
>
<LucideUpload />
{t('admin.exportAsExcel')}
</Button>
<Button
variant="outline"
className="h-10 px-4 dark:bg-bg-input dark:border-border-button text-text-secondary"
onClick={() => setImportModalOpen(true)}
>
<LucideDownload />
{t('admin.importFromExcel')}
</Button>
<Button
className="h-10 px-4"
onClick={() => setCreateModalOpen(true)}
>
<LucidePlus />
{t('admin.createEmail')}
</Button>
</div>
</CardHeader>
<CardContent>
<Table>
<colgroup>
<col />
<col className="w-[20%]" />
<col className="w-[30%]" />
<col className="w-[12rem]" />
</colgroup>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} className="group">
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableEmpty columnsLength={columnDefs.length} />
)}
</TableBody>
</Table>
</CardContent>
<CardFooter className="flex items-center justify-end">
<RAGFlowPagination
total={table.getFilteredRowModel().rows.length}
current={table.getState().pagination.pageIndex + 1}
pageSize={table.getState().pagination.pageSize}
onChange={(page, pageSize) => {
table.setPagination({
pageIndex: page - 1,
pageSize,
});
}}
/>
</CardFooter>
</ScrollArea>
</Card>
{/* Delete Confirmation Modal */}
<Dialog open={deleteModalOpen} onOpenChange={setDeleteModalOpen}>
<DialogContent
className="p-0 border-border-button"
onAnimationEnd={() => {
if (!deleteModalOpen) {
setEmailToMakeAction(null);
}
}}
>
<DialogHeader className="p-6 border-b border-border-button">
<DialogTitle>{t('admin.deleteEmail')}</DialogTitle>
</DialogHeader>
<section className="px-12 py-4">
<DialogDescription className="text-text-primary">
{t('admin.deleteWhitelistEmailConfirmation')}
<div className="rounded-lg mt-6 p-4 border border-border-button">
{emailToMakeAction}
</div>
</DialogDescription>
</section>
<DialogFooter className="flex justify-end gap-4 px-12 pt-4 pb-8">
<Button
className="px-4 h-10 dark:border-border-button"
variant="outline"
onClick={() => setDeleteModalOpen(false)}
disabled={isDeleting}
>
{t('admin.cancel')}
</Button>
<LoadingButton
className="px-4 h-10"
variant="destructive"
onClick={() => {
deleteEmail({ email: emailToMakeAction! });
}}
disabled={isDeleting}
loading={isDeleting}
>
{t('admin.delete')}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Create Email Modal */}
<Dialog
open={createModalOpen}
onOpenChange={() => {
setCreateModalOpen(false);
createEmailForm.form.reset();
}}
>
<DialogContent className="p-0 border-border-button">
<DialogHeader className="p-6 border-b border-border-button">
<DialogTitle>{t('admin.createEmail')}</DialogTitle>
</DialogHeader>
<section className="px-12 py-4 text-text-secondary">
<createEmailForm.FormComponent
id={createEmailForm.id}
onSubmit={createEmail}
/>
</section>
<DialogFooter className="flex justify-end gap-4 px-12 pt-4 pb-8">
<Button
className="px-4 h-10 dark:border-border-button"
variant="outline"
onClick={() => {
setCreateModalOpen(false);
createEmailForm.form.reset();
}}
disabled={isCreating}
>
{t('admin.cancel')}
</Button>
<LoadingButton
form={createEmailForm.id}
type="submit"
className="px-4 h-10"
variant="default"
disabled={isCreating}
loading={isCreating}
>
{t('admin.confirm')}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Email Modal */}
<Dialog
open={editModalOpen}
onOpenChange={() => {
setEditModalOpen(false);
setEmailToMakeAction(null);
createEmailForm.form.reset();
}}
>
<DialogContent
className="p-0 border-border-button"
onAnimationEnd={() => {
if (!editModalOpen) {
setEmailToMakeAction(null);
createEmailForm.form.reset();
}
}}
>
<DialogHeader className="p-6 border-b border-border-button">
<DialogTitle>{t('admin.editEmail')}</DialogTitle>
</DialogHeader>
<section className="px-12 py-4 text-text-secondary">
<createEmailForm.FormComponent
id={createEmailForm.id}
onSubmit={updateEmail}
/>
</section>
<DialogFooter className="flex justify-end gap-4 px-12 pt-4 pb-8">
<Button
className="px-4 h-10 dark:border-border-button"
variant="outline"
onClick={() => {
setEditModalOpen(false);
setEmailToMakeAction(null);
createEmailForm.form.reset();
}}
disabled={isEditing}
>
{t('admin.cancel')}
</Button>
<LoadingButton
form={createEmailForm.id}
type="submit"
className="px-4 h-10"
variant="default"
disabled={isEditing}
loading={isEditing}
>
{t('admin.confirm')}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Import Excel Modal */}
<Dialog open={importModalOpen} onOpenChange={setImportModalOpen}>
<DialogContent className="p-0 border-border-button">
<DialogHeader className="p-6 border-b border-border-button">
<DialogTitle>{t('admin.importWhitelist')}</DialogTitle>
</DialogHeader>
<section className="px-12 py-4 text-text-secondary">
<importExcelForm.FormComponent
id={importExcelForm.id}
onSubmit={importExcel}
/>
</section>
<DialogFooter className="flex justify-end gap-4 px-12 pt-4 pb-8">
<Button
className="px-4 h-10 dark:border-border-button"
variant="outline"
onClick={() => {
setImportModalOpen(false);
importExcelForm.form.reset();
}}
disabled={isImporting}
>
{t('admin.cancel')}
</Button>
<LoadingButton
form={importExcelForm.id}
type="submit"
className="px-4 h-10"
variant="default"
disabled={isImporting}
loading={isImporting}
>
{t('admin.import')}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
export default AdminWhitelist;

View File

@@ -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;

View File

@@ -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<D = {}> = {
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<string, unknown>;
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<string, any> | Record<string, any>[];
};
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<string, PermissionData>;
};
export type RoleDetailWithPermission = {
role: {
id: string;
name: string;
description: string;
};
permissions: Record<string, PermissionData>;
};
export type RoleDetail = {
id: string;
name: string;
descrtiption: string;
create_date: string;
update_date: string;
};
export type AssignRolePermissionInput = {
permissions: Record<string, Partial<PermissionData>>;
};
export type RevokeRolePermissionInput = AssignRolePermissionInput;
export type UserDetailWithPermission = {
user: {
id: string;
username: string;
role: string;
};
role_permissions: Record<string, PermissionData>;
};
export type ResourceType = {
resource_types: string[];
};
}
export const login = (params: { email: string; password: string }) =>
request.post<ResponseData<AdminService.LoginData>>(adminLogin, params);
export const logout = () => request.get<ResponseData<boolean>>(adminLogout);
export const listUsers = () =>
request.get<ResponseData<AdminService.ListUsersItem[]>>(adminListUsers, {});
export const createUser = (email: string, password: string) =>
request.post<ResponseData<boolean>>(adminCreateUser, {
username: email,
password,
});
export const getUserDetails = (email: string) =>
request.get<ResponseData<[AdminService.UserDetail]>>(
adminShowUserDetails(email),
);
export const listUserDatasets = (email: string) =>
request.get<ResponseData<AdminService.ListUserDatasetItem[]>>(
adminListUserDatasets(email),
);
export const listUserAgents = (email: string) =>
request.get<ResponseData<AdminService.ListUserAgentItem[]>>(
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<ResponseData<AdminService.ListServicesItem[]>>(adminListServices);
export const showServiceDetails = (serviceId: number) =>
request.get<ResponseData<AdminService.ServiceDetail>>(
adminShowServiceDetails(String(serviceId)),
);
export const createRole = (params: { roleName: string; description: string }) =>
request.post<ResponseData<AdminService.RoleDetail>>(adminCreateRole, params);
export const updateRoleDescription = (role: string, description: string) =>
request.put<ResponseData<AdminService.RoleDetail>>(
adminUpdateRoleDescription(role),
{ description },
);
export const deleteRole = (role: string) =>
request.delete<ResponseData<ResponseData<never>>>(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<ResponseData<AdminService.RoleDetailWithPermission>>(
adminGetRolePermissions(role),
);
export const assignRolePermissions = (
role: string,
params: AdminService.AssignRolePermissionInput,
) =>
request.post<ResponseData<never>>(adminAssignRolePermissions(role), params);
export const revokeRolePermissions = (
role: string,
params: AdminService.RevokeRolePermissionInput,
) =>
request.delete<ResponseData<never>>(adminRevokeRolePermissions(role), {
data: params,
});
export const updateUserRole = (username: string, role: string) =>
request.put<ResponseData<never>>(adminUpdateUserRole(username), {
role_name: role,
});
export const getUserPermissions = (username: string) =>
request.get<ResponseData<AdminService.UserDetailWithPermission>>(
adminGetUserPermissions(username),
);
export const listResources = () =>
request.get<ResponseData<AdminService.ResourceType>>(adminListResources);
export default {
login,
logout,
listUsers,
createUser,
showUserDetails: getUserDetails,
updateUserStatus,
updateUserPassword,
deleteUser,
listUserDatasets,
listUserAgents,
};

View File

@@ -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`,
};

View File

@@ -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 ? <Outlet /> : <Navigate to={Routes.Admin} />;
};