mirror of
https://gitee.com/infiniflow/ragflow.git
synced 2025-12-06 07:19:03 +08:00
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:
@@ -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/',
|
||||
|
||||
@@ -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>
|
||||
));
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
13
web/src/pages/admin/components/enterprise-feature.tsx
Normal file
13
web/src/pages/admin/components/enterprise-feature.tsx
Normal 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;
|
||||
}
|
||||
47
web/src/pages/admin/components/theme-switch.tsx
Normal file
47
web/src/pages/admin/components/theme-switch.tsx
Normal 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;
|
||||
150
web/src/pages/admin/forms/change-password-form.tsx
Normal file
150
web/src/pages/admin/forms/change-password-form.tsx
Normal 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;
|
||||
102
web/src/pages/admin/forms/email-form.tsx
Normal file
102
web/src/pages/admin/forms/email-form.tsx
Normal 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;
|
||||
155
web/src/pages/admin/forms/import-excel-form.tsx
Normal file
155
web/src/pages/admin/forms/import-excel-form.tsx
Normal 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;
|
||||
207
web/src/pages/admin/forms/role-form.tsx
Normal file
207
web/src/pages/admin/forms/role-form.tsx
Normal 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;
|
||||
215
web/src/pages/admin/forms/user-form.tsx
Normal file
215
web/src/pages/admin/forms/user-form.tsx
Normal 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;
|
||||
265
web/src/pages/admin/index.tsx
Normal file
265
web/src/pages/admin/index.tsx
Normal 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;
|
||||
133
web/src/pages/admin/layout.tsx
Normal file
133
web/src/pages/admin/layout.tsx
Normal 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;
|
||||
13
web/src/pages/admin/monitoring.tsx
Normal file
13
web/src/pages/admin/monitoring.tsx
Normal 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;
|
||||
229
web/src/pages/admin/roles.tsx
Normal file
229
web/src/pages/admin/roles.tsx
Normal 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;
|
||||
86
web/src/pages/admin/service-detail.tsx
Normal file
86
web/src/pages/admin/service-detail.tsx
Normal 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;
|
||||
460
web/src/pages/admin/service-status.tsx
Normal file
460
web/src/pages/admin/service-status.tsx
Normal 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;
|
||||
433
web/src/pages/admin/user-detail.tsx
Normal file
433
web/src/pages/admin/user-detail.tsx
Normal 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;
|
||||
691
web/src/pages/admin/users.tsx
Normal file
691
web/src/pages/admin/users.tsx
Normal 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;
|
||||
85
web/src/pages/admin/utils.tsx
Normal file
85
web/src/pages/admin/utils.tsx
Normal 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';
|
||||
522
web/src/pages/admin/whitelist.tsx
Normal file
522
web/src/pages/admin/whitelist.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
379
web/src/services/admin-service.ts
Normal file
379
web/src/services/admin-service.ts
Normal 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,
|
||||
};
|
||||
@@ -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`,
|
||||
};
|
||||
|
||||
9
web/src/wrappers/authAdmin.tsx
Normal file
9
web/src/wrappers/authAdmin.tsx
Normal 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} />;
|
||||
};
|
||||
Reference in New Issue
Block a user