Feat: Admin UI whitelist management and role management (#10910)

### What problem does this PR solve?

Add whitelist management and role management in Admin UI

### Type of change

- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
Jimmy Ben Klieve
2025-11-03 09:52:23 +08:00
committed by GitHub
parent 685311814f
commit 7ec587fa9e
20 changed files with 1037 additions and 1004 deletions

650
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -114,6 +114,7 @@
"umi-request": "^1.4.0",
"unist-util-visit-parents": "^6.0.1",
"uuid": "^9.0.1",
"xlsx": "^0.18.5",
"zod": "^3.23.8",
"zustand": "^4.5.2"
},

View File

@ -1,12 +1,16 @@
import { cn } from '@/lib/utils';
import { t } from 'i18next';
import { useIsDarkTheme } from '../theme-provider';
type EmptyProps = {
className?: string;
children?: React.ReactNode;
};
const EmptyIcon = () => (
const EmptyIcon = () => {
const isDarkTheme = useIsDarkTheme();
return (
<svg
width="184"
height="152"
@ -18,7 +22,7 @@ const EmptyIcon = () => (
<g transform="translate(24 31.67)">
<ellipse
fillOpacity=".8"
fill="#F5F5F7"
fill={isDarkTheme ? '#28282A' : '#F5F5F7'}
cx="67.797"
cy="106.89"
rx="67.797"
@ -26,7 +30,7 @@ const EmptyIcon = () => (
></ellipse>
<path
d="M122.034 69.674L98.109 40.229c-1.148-1.386-2.826-2.225-4.593-2.225h-51.44c-1.766 0-3.444.839-4.592 2.225L13.56 69.674v15.383h108.475V69.674z"
fill="#AEB8C2"
fill={isDarkTheme ? '#736960' : '#AEB8C2'}
></path>
<path
d="M101.537 86.214L80.63 61.102c-1.001-1.207-2.507-1.867-4.048-1.867H31.724c-1.54 0-3.047.66-4.048 1.867L6.769 86.214v13.792h94.768V86.214z"
@ -35,24 +39,28 @@ const EmptyIcon = () => (
></path>
<path
d="M33.83 0h67.933a4 4 0 0 1 4 4v93.344a4 4 0 0 1-4 4H33.83a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4z"
fill="#F5F5F7"
fill={isDarkTheme ? '#28282A' : '#F5F5F7'}
></path>
<path
d="M42.678 9.953h50.237a2 2 0 0 1 2 2V36.91a2 2 0 0 1-2 2H42.678a2 2 0 0 1-2-2V11.953a2 2 0 0 1 2-2zM42.94 49.767h49.713a2.262 2.262 0 1 1 0 4.524H42.94a2.262 2.262 0 0 1 0-4.524zM42.94 61.53h49.713a2.262 2.262 0 1 1 0 4.525H42.94a2.262 2.262 0 0 1 0-4.525zM121.813 105.032c-.775 3.071-3.497 5.36-6.735 5.36H20.515c-3.238 0-5.96-2.29-6.734-5.36a7.309 7.309 0 0 1-.222-1.79V69.675h26.318c2.907 0 5.25 2.448 5.25 5.42v.04c0 2.971 2.37 5.37 5.277 5.37h34.785c2.907 0 5.277-2.421 5.277-5.393V75.1c0-2.972 2.343-5.426 5.25-5.426h26.318v33.569c0 .617-.077 1.216-.221 1.789z"
fill="#DCE0E6"
fill={isDarkTheme ? '#45413A' : '#DCE0E6'}
></path>
</g>
<path
d="M149.121 33.292l-6.83 2.65a1 1 0 0 1-1.317-1.23l1.937-6.207c-2.589-2.944-4.109-6.534-4.109-10.408C138.802 8.102 148.92 0 161.402 0 173.881 0 184 8.102 184 18.097c0 9.995-10.118 18.097-22.599 18.097-4.528 0-8.744-1.066-12.28-2.902z"
fill="#DCE0E6"
fill={isDarkTheme ? '#45413A' : '#DCE0E6'}
></path>
<g transform="translate(149.65 15.383)" fill="#FFF">
<g
transform="translate(149.65 15.383)"
fill={isDarkTheme ? '#222' : '#FFF'}
>
<ellipse cx="20.654" cy="3.167" rx="2.849" ry="2.815"></ellipse>
<path d="M5.698 5.63H0L2.898.704zM9.259.704h4.985V5.63H9.259z"></path>
</g>
</g>
</svg>
);
};
const Empty = (props: EmptyProps) => {
const { className, children } = props;

View File

@ -1960,7 +1960,13 @@ Important structured information may include: names, dates, locations, events, k
newRole: 'New Role',
addNewRole: 'Add new role',
roleName: 'Role name',
roleNameRequired: 'Role name is required',
resources: 'Resources',
editRoleDescription: 'Edit role description',
deleteRole: 'Delete role',
deleteRoleConfirmation:
'Are you sure you want to delete this role? This action cannot be undone.',
},
},
};

View File

@ -3,7 +3,7 @@ import { IS_ENTERPRISE } from '../utils';
export default function EnterpriseFeature({
children,
}: {
children: () => React.ReactNode;
children: React.ReactNode | (() => React.ReactNode);
}) {
return IS_ENTERPRISE
? typeof children === 'function'

View File

@ -15,7 +15,7 @@ const ThemeSwitch = forwardRef<
return (
<Root
ref={ref}
className={cn('relative rounded-full')}
className={cn('relative rounded-full', className)}
{...props}
checked={isDark}
onCheckedChange={(value) =>

View File

@ -1,4 +1,3 @@
import { Checkbox } from '@/components/ui/checkbox';
import {
Form,
FormControl,
@ -14,8 +13,8 @@ import { useForm } from 'react-hook-form';
import { Trans, useTranslation } from 'react-i18next';
import { z } from 'zod';
interface ImportExcelFormData {
file: FileList;
export interface ImportExcelFormData {
file: File;
overwriteExisting: boolean;
}
@ -43,6 +42,7 @@ export const ImportExcelForm = ({
<FormField
control={form.control}
name="file"
// eslint-disable-next-line @typescript-eslint/no-unused-vars
render={({ field: { onChange, value, ...field } }) => (
<FormItem>
<FormLabel className="text-sm font-medium">
@ -56,7 +56,7 @@ export const ImportExcelForm = ({
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);
onChange(files?.[0]);
}}
{...field}
/>
@ -66,27 +66,7 @@ export const ImportExcelForm = ({
)}
/>
{/* 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">
<p className="text-sm text-text-secondary">
<Trans
i18nKey="admin.importFileTips"
components={{ code: <code /> }}
@ -105,21 +85,14 @@ function useImportExcelForm() {
const schema = useMemo(() => {
return z.object({
file: z
.any()
.refine((files) => files && files.length > 0, {
message: t('admin.importFileRequired'),
})
.instanceof(File, { message: t('admin.importFileRequired') })
.refine(
(files) => {
if (!files || files.length === 0) return false;
const [file] = files;
(file) => {
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'),

View File

@ -1,3 +1,11 @@
import { useCallback, useId, useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { z } from 'zod';
import { Card, CardContent } from '@/components/ui/card';
import {
Form,
@ -16,15 +24,11 @@ import {
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 {
import { listResources } from '@/services/admin-service';
import { PERMISSION_TYPES, formMergeDefaultValues } from '../utils';
export interface CreateRoleFormData {
name: string;
description: string;
permissions: Record<string, AdminService.PermissionData>;
@ -36,8 +40,6 @@ interface CreateRoleFormProps {
onSubmit?: (data: CreateRoleFormData) => void;
}
const PERMISSION_TYPES = ['enable', 'read', 'write', 'share'] as const;
export const CreateRoleForm = ({
id,
form,
@ -48,6 +50,7 @@ export const CreateRoleForm = ({
const { data: resourceTypes } = useQuery({
queryKey: ['admin/resourceTypes'],
queryFn: async () => (await listResources()).data.data.resource_types,
retry: false,
});
return (
@ -108,9 +111,9 @@ export const CreateRoleForm = ({
<TabsTrigger
key={resourceType}
value={resourceType}
className="text-text-secondary border-border-button dark:data-[state=active]:bg-bg-input"
className="text-text-secondary !border-border-button data-[state=active]:bg-bg-card data-[state=active]:text-text-primary"
>
{t(`admin.resourceType.${resourceType}`)}
{t(`admin.resourceType.${resourceType.toLowerCase()}`)}
</TabsTrigger>
))}
</TabsList>
@ -121,7 +124,7 @@ export const CreateRoleForm = ({
value={resourceType}
className="space-y-4"
>
<Card className="border-0 bg-bg-card">
<Card className="border-0 bg-bg-card !shadow-none">
<CardContent className="p-6">
<div className="grid grid-cols-4 gap-4">
{PERMISSION_TYPES.map((permissionType) => (
@ -129,8 +132,8 @@ export const CreateRoleForm = ({
key={permissionType}
name={`permissions.${resourceType}.${permissionType}`}
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
<FormItem className="space-y-0 inline-flex items-center gap-2">
<FormLabel>
{t(`admin.permissionType.${permissionType}`)}
</FormLabel>
<FormControl>
@ -158,42 +161,46 @@ export const CreateRoleForm = ({
// Export the form validation state for parent component
function useCreateRoleForm(props?: {
defaultValues: Partial<CreateRoleFormData>;
defaultValues:
| Partial<CreateRoleFormData>
| (() => Promise<CreateRoleFormData>);
}) {
const { t } = useTranslation();
const id = useId();
const schema = useMemo(() => {
return z.object({
name: z.string().min(1, { message: 'Role name is required' }),
name: z.string().min(1, { message: t('admin.roleNameRequired') }),
description: z.string().optional(),
permissions: z.record(
z.string(),
z.object({
enable: z.boolean(),
read: z.boolean(),
write: z.boolean(),
share: z.boolean(),
enable: z.boolean().optional(),
read: z.boolean().optional(),
write: z.boolean().optional(),
share: z.boolean().optional(),
}),
),
});
}, [t]);
const form = useForm<CreateRoleFormData>({
defaultValues: {
defaultValues: formMergeDefaultValues(
{
name: '',
description: '',
permissions: {},
...(props?.defaultValues ?? {}),
},
props?.defaultValues,
),
resolver: zodResolver(schema),
});
const FormComponent = useCallback(
(props: Partial<CreateRoleFormProps>) => (
<CreateRoleForm id="create-role-form" form={form} {...props} />
<CreateRoleForm id={id} form={form} {...props} />
),
[form],
[id, form],
);
return {

View File

@ -2,7 +2,7 @@ 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 { logout } from '@/services/admin-service';
import authorizationUtil from '@/utils/authorization-util';
import { useMutation } from '@tanstack/react-query';
import {
@ -60,7 +60,7 @@ const AdminLayout = () => {
const logoutMutation = useMutation({
mutationKey: ['adminLogout'],
mutationFn: async () => {
await adminService.logout();
await logout();
message.success(t('message.logout'));
authorizationUtil.removeAll();

View File

@ -2,7 +2,7 @@ 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">
<Card className="!shadow-none h-full border border-border-button bg-transparent rounded-xl overflow-x-hidden overflow-y-auto">
<CardContent className="size-full p-0">
<iframe />
</CardContent>

View File

@ -1,10 +1,21 @@
import { useState } from 'react';
import { mapKeys } from 'lodash';
import { useId, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Card, CardContent, 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 { ScrollArea } from '@/components/ui/scroll-area';
@ -18,71 +29,134 @@ import {
import { LucideEdit3, LucideTrash2, LucideUserPlus } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
AdminService,
assignRolePermissions,
createRole,
deleteRole,
listResources,
listRolesWithPermission,
revokeRolePermissions,
updateRoleDescription,
} from '@/services/admin-service';
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
import Empty from '@/components/empty/empty';
import useCreateRoleForm, { CreateRoleFormData } from './forms/role-form';
import { PERMISSION_TYPES } from './utils';
function AdminRoles() {
const { t } = useTranslation();
const [isAddRoleModalOpen, setIsAddRoleModalOpen] = useState(false);
const queryClient = useQueryClient();
const createRoleForm = useCreateRoleForm();
const [isAddRoleModalOpen, setAddRoleModalOpen] = useState(false);
const editRoleDescriptionFormId = useId();
const [isEditRoleDescriptionModalOpen, setEditRoleDescriptionModalOpen] =
useState(false);
const [roleDescription, setRoleDescription] = useState('');
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [roleToMakeAction, setRoleToMakeAction] =
useState<AdminService.ListRoleItemWithPermission | null>(null);
const { data: roleList } = useQuery({
queryKey: ['admin/listRolesWithPermission'],
queryFn: async () => (await listRolesWithPermission()).data.data.roles,
queryFn: async () => (await listRolesWithPermission())?.data?.data?.roles,
retry: false,
});
const createRoleForm = useCreateRoleForm();
const { data: resourceTypes } = useQuery({
queryKey: ['admin/resourceTypes'],
queryFn: async () => (await listResources()).data.data.resource_types,
retry: false,
});
const handleAddRole = (data: any) => {
console.log('New role data:', data);
// TODO: Implement actual role creation logic
createRoleForm.form.reset();
setIsAddRoleModalOpen(false);
const updateRoleDescriptionMutation = useMutation({
mutationFn: (data: { name: string; description: string }) =>
updateRoleDescription(data.name, data.description),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['admin/listRolesWithPermission'],
});
setEditRoleDescriptionModalOpen(false);
setRoleToMakeAction(null);
},
retry: false,
});
const updateRolePermissionsMutation = useMutation({
mutationFn: (data: {
name: string;
resourceName: string;
permissionType: (typeof PERMISSION_TYPES)[number];
value: boolean;
}) => {
const permissionDiffData = {
[data.resourceName.toLowerCase()]: {
[data.permissionType]: data.value,
},
};
return data.value
? assignRolePermissions(data.name, permissionDiffData)
: revokeRolePermissions(data.name, permissionDiffData);
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['admin/listRolesWithPermission'],
});
},
retry: false,
});
const createRoleMutation = useMutation({
mutationFn: async (data: CreateRoleFormData) => {
const { data: { data: createdRoleDetail } = {} } = await createRole({
roleName: data.name,
description: data.description,
});
if (!createdRoleDetail) {
throw new Error();
}
await assignRolePermissions(
data.name,
mapKeys(data.permissions, (_, key) => key.toLowerCase()),
);
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['admin/listRolesWithPermission'],
});
createRoleForm.form.reset();
setAddRoleModalOpen(false);
},
retry: false,
});
const deleteRoleMutation = useMutation({
mutationFn: (roleName: string) => deleteRole(roleName),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['admin/listRolesWithPermission'],
});
setDeleteModalOpen(false);
setRoleToMakeAction(null);
},
retry: false,
});
return (
<>
<Card className="h-full border border-border-button bg-transparent rounded-xl">
<Card className="!shadow-none w-full 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)}
onClick={() => setAddRoleModalOpen(true)}
>
<LucideUserPlus />
{t('admin.newRole')}
@ -90,20 +164,19 @@ function AdminRoles() {
</CardHeader>
<CardContent className="space-y-6">
{roleList?.map((role) => {
const resources = Object.entries(role.permissions);
return (
{roleList?.length ? (
roleList.map((role) => (
<Card
key={role.id}
className="group border border-border-default bg-transparent hover:bg-bg-card transition-color duration-150"
className="group border border-border-default bg-transparent dark: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">
<CardHeader className="space-y-0 flex flex-row gap-4 items-center border-b border-border-button">
<div className="space-y-1.5 w-0 flex-1">
<CardTitle className="font-normal text-xl">
{role.role_name}
</CardTitle>
<div className="text-sm text-text-secondary">
<div className="text-sm text-text-secondary break-words">
{role.description || (
<i className="text-muted-foreground">
{t('admin.noDescription')}
@ -113,6 +186,11 @@ function AdminRoles() {
<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"
onClick={() => {
setEditRoleDescriptionModalOpen(true);
setRoleToMakeAction(role);
setRoleDescription(role.description || '');
}}
>
<LucideEdit3 className="!size-[1em]" />
</Button>
@ -123,6 +201,11 @@ function AdminRoles() {
variant="ghost"
size="icon"
className="ml-auto opacity-0 group-hover:opacity-100 group-focus-within:opacity-100"
disabled={deleteRoleMutation.isPending}
onClick={() => {
setDeleteModalOpen(true);
setRoleToMakeAction(role);
}}
>
<LucideTrash2 />
</Button>
@ -131,82 +214,95 @@ function AdminRoles() {
<CardContent className="p-6">
<Tabs
className="h-full flex flex-col"
defaultValue={resources[0]?.[0]}
defaultValue={resourceTypes?.[0]}
>
<TabsList className="p-0 mb-2 gap-4 bg-transparent">
{resources.map(([name]) => (
{resourceTypes?.map((resourceName) => (
<TabsTrigger
key={name}
value={name}
className="border-border-button dark:data-[state=active]:bg-bg-input"
key={resourceName}
value={resourceName}
className="text-text-secondary !border-border-button data-[state=active]:bg-bg-card data-[state=active]:text-text-primary"
>
{t(`admin.resourceType.${name}`)}
{t(
`admin.resourceType.${resourceName.toLowerCase()}`,
)}
</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>
{resourceTypes?.map((resourceName) => {
const permission =
role.permissions[resourceName.toLowerCase()];
<Label className="flex items-center gap-2">
<Switch
checked={!!permission.read}
onCheckedChange={() => {}}
/>
{t('admin.read')}
</Label>
return (
<TabsContent key={resourceName} value={resourceName}>
<Card className="border-0 bg-bg-card !shadow-none">
<CardContent className="p-6 flex gap-8">
{PERMISSION_TYPES.map((permissionType) => (
<Label
key={permissionType}
className="flex items-center gap-2"
>
{t(`admin.${permissionType}`)}
<Label className="flex items-center gap-2">
<Switch
checked={!!permission.write}
onCheckedChange={() => {}}
disabled={
updateRolePermissionsMutation.isPending
}
checked={!!permission?.[permissionType]}
onCheckedChange={(value) =>
updateRolePermissionsMutation.mutate({
name: role.role_name,
resourceName:
resourceName.toLowerCase(),
permissionType,
value,
})
}
/>
{t('admin.write')}
</Label>
<Label className="flex items-center gap-2">
<Switch
checked={!!permission.share}
onCheckedChange={() => {}}
/>
{t('admin.share')}
</Label>
</div>
</TabsContent>
))}
</CardContent>
</Card>
</TabsContent>
);
})}
</Tabs>
</CardContent>
</Card>
);
})}
))
) : (
<Empty className="py-24" />
)}
</CardContent>
</ScrollArea>
</Card>
{/* Add Role Modal */}
<Dialog open={isAddRoleModalOpen} onOpenChange={setIsAddRoleModalOpen}>
<DialogContent className="max-w-2xl p-0 border-border-button">
{/* Add role modal */}
<Dialog open={isAddRoleModalOpen} onOpenChange={setAddRoleModalOpen}>
<DialogContent
className="max-w-2xl p-0 border-border-button"
onAnimationEnd={() => {
if (!isAddRoleModalOpen) {
createRoleForm.form.reset();
}
}}
>
<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} />
<createRoleForm.FormComponent
onSubmit={createRoleMutation.mutate}
/>
</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)}
onClick={() => setAddRoleModalOpen(false)}
>
{t('admin.cancel')}
</Button>
@ -222,6 +318,118 @@ function AdminRoles() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* Modify role description modal */}
<Dialog
open={isEditRoleDescriptionModalOpen}
onOpenChange={setEditRoleDescriptionModalOpen}
>
<DialogContent
className="p-0 border-border-button"
onAnimationEnd={() => {
if (!isEditRoleDescriptionModalOpen) {
setRoleToMakeAction(null);
}
}}
>
<DialogHeader className="p-6 border-b border-border-button">
<DialogTitle>{t('admin.editRoleDescription')}</DialogTitle>
</DialogHeader>
<section className="px-12 py-4">
<form
id={editRoleDescriptionFormId}
onSubmit={(evt) => {
evt.preventDefault();
updateRoleDescriptionMutation.mutate({
name: roleToMakeAction!?.role_name,
description: roleDescription.trim(),
});
}}
>
<Label>
<div className="text-sm font-medium">
{t('admin.description')}
</div>
<Input
className="mt-2 px-3 h-10 bg-bg-input border-border-button"
value={roleDescription}
onInput={(evt) => setRoleDescription(evt.currentTarget.value)}
/>
</Label>
</form>
</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={() => setEditRoleDescriptionModalOpen(false)}
>
{t('admin.cancel')}
</Button>
<LoadingButton
type="submit"
form={editRoleDescriptionFormId}
className="px-4 h-10"
>
{t('admin.confirm')}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete role modal */}
<Dialog open={deleteModalOpen} onOpenChange={setDeleteModalOpen}>
<DialogContent
className="p-0 border-border-button"
onAnimationEnd={() => {
if (!deleteModalOpen) {
setRoleToMakeAction(null);
}
}}
>
<DialogHeader className="p-6 border-b border-border-button">
<DialogTitle>{t('admin.deleteRole')}</DialogTitle>
</DialogHeader>
<section className="px-12 py-4">
<DialogDescription className="text-text-primary">
{t('admin.deleteRoleConfirmation')}
</DialogDescription>
<div className="rounded-lg mt-6 p-4 border border-border-button">
{roleToMakeAction?.role_name}
</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={() => setDeleteModalOpen(false)}
disabled={deleteRoleMutation.isPending}
>
{t('admin.cancel')}
</Button>
<LoadingButton
className="px-4 h-10"
variant="destructive"
onClick={() =>
roleToMakeAction &&
deleteRoleMutation.mutate(roleToMakeAction!?.role_name)
}
disabled={deleteRoleMutation.isPending}
loading={deleteRoleMutation.isPending}
>
{t('admin.delete')}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -44,7 +44,7 @@ function ServiceDetail({ content }: ServiceDetailProps) {
if (isPlainObject(content)) {
return (
<dl className="grid grid-cols-[auto,1fr] border border-card rounded-xl overflow-hidden bg-bg-card">
<dl className="text-sm text-text-primary 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">

View File

@ -59,19 +59,13 @@ import {
TableRow,
} from '@/components/ui/table';
import {
listServices,
showServiceDetails,
type AdminService,
} from '@/services/admin-service';
import { listServices, showServiceDetails } from '@/services/admin-service';
import {
EMPTY_DATA,
createColumnFilterFn,
createFuzzySearchFn,
getColumnFilter,
getSortIcon,
setColumnFilter,
} from './utils';
import ServiceDetail from './service-detail';
@ -97,22 +91,18 @@ function AdminServiceStatus() {
const [itemToMakeAction, setItemToMakeAction] =
useState<AdminService.ListServicesItem | null>(null);
const { data: servicesList, isPending } = useQuery({
const { data: servicesList } = useQuery({
queryKey: ['admin/listServices'],
queryFn: async () => (await listServices()).data.data,
retry: false,
});
const {
data: serviceDetails,
isPending: isServiceDetailsPending,
error: serviceDetailsError,
} = useQuery({
const { data: serviceDetails, error: serviceDetailsError } = useQuery({
queryKey: ['admin/serviceDetails', itemToMakeAction?.id],
queryFn: async () =>
(await showServiceDetails(itemToMakeAction?.id!)).data.data,
(await showServiceDetails(itemToMakeAction!?.id)).data.data,
enabled: !!(itemToMakeAction && detailModalOpen),
retry: false,
refetchInterval: Infinity,
});
const columnDefs = useMemo(
@ -202,7 +192,7 @@ function AdminServiceStatus() {
),
}),
],
[],
[t],
);
const table = useReactTable({
@ -225,7 +215,7 @@ function AdminServiceStatus() {
return (
<>
<Card className="h-full border border-border-button bg-transparent rounded-xl">
<Card className="!shadow-none 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>
@ -254,11 +244,12 @@ function AdminServiceStatus() {
<RadioGroup
value={
(getColumnFilter(table, 'service_type')
?.value as string) ?? ''
table
.getColumn('service_type')!
?.getFilterValue() as string
}
onValueChange={(value) =>
setColumnFilter(table, 'service_type', value)
onValueChange={
table.getColumn('service_type')!?.setFilterValue
}
>
<Label className="space-x-2">

View File

@ -41,7 +41,6 @@ import {
getUserDetails,
listUserAgents,
listUserDatasets,
type AdminService,
} from '@/services/admin-service';
import EnterpriseFeature from './components/enterprise-feature';
@ -323,7 +322,7 @@ function AdminUserDetail() {
</Button>
</nav>
<Card className="h-0 basis-0 grow flex flex-col bg-transparent border dark:border-border-button overflow-hidden">
<Card className="!shadow-none 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">

View File

@ -84,7 +84,6 @@ import {
updateUserPassword,
updateUserRole,
updateUserStatus,
type AdminService,
} from '@/services/admin-service';
import {
@ -130,7 +129,7 @@ function AdminUserManagement() {
retry: false,
});
const { data: usersList, isPending } = useQuery({
const { data: usersList } = useQuery({
queryKey: ['admin/listUsers'],
queryFn: async () => (await listUsers()).data.data,
retry: false,
@ -341,13 +340,7 @@ function AdminUserManagement() {
),
}),
],
[
roleList,
t,
navigate,
updateUserStatusMutation.isPending,
updateUserRoleMutation.isPending,
],
[t, updateUserRoleMutation, roleList, updateUserStatusMutation, navigate],
);
const table = useReactTable({
@ -364,7 +357,7 @@ function AdminUserManagement() {
return (
<>
<Card className="h-full border border-border-button bg-transparent rounded-xl overflow-x-hidden overflow-y-auto">
<Card className="!shadow-none 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>

View File

@ -4,10 +4,35 @@ import {
Row,
RowData,
SortDirection,
Table,
TransformFilterValueFn,
} from '@tanstack/react-table';
import { LucideSortAsc, LucideSortDesc } from 'lucide-react';
import { DefaultValues } from 'react-hook-form';
type AsyncDefaultValues<TValues> = (payload?: unknown) => Promise<TValues>;
export function formMergeDefaultValues<T>(
...parts: (DefaultValues<T> | AsyncDefaultValues<T> | undefined)[]
): (payload?: unknown) => Promise<Required<T>> {
return async (payload?: unknown) => {
if (parts.length === 0) {
return {} as DefaultValues<T>;
}
if (parts.length === 1) {
return typeof parts[0] === 'function'
? await parts[0](payload)
: parts[0];
}
return Object.assign(
// @ts-ignore
...(await Promise.all(
parts.map((p) => (typeof p === 'function' ? p(payload) : p)),
)),
);
};
}
export function parseBooleanish(value: any): boolean {
return typeof value === 'string'
@ -42,37 +67,6 @@ export function createColumnFilterFn<TData extends RowData>(
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 />,
@ -80,6 +74,7 @@ export function getSortIcon(sorting: false | SortDirection) {
}[sorting as string];
}
export const PERMISSION_TYPES = ['enable', 'read', 'write', 'share'] as const;
export const EMPTY_DATA = Object.freeze<any[]>([]) as any[];
export const IS_ENTERPRISE =
process.env.UMI_APP_RAGFLOW_ENTERPRISE === 'RAGFLOW_ENTERPRISE';

View File

@ -1,3 +1,27 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import * as XLSX from 'xlsx';
import { useMutation, useQuery, 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 { TableEmpty } from '@/components/table-skeleton';
import { Button } from '@/components/ui/button';
import {
@ -27,45 +51,27 @@ import {
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';
createWhitelistEntry,
deleteWhitelistEntry,
importWhitelistFromExcel,
listWhitelist,
updateWhitelistEntry,
type AdminService,
} from '@/services/admin-service';
import { EMPTY_DATA, createFuzzySearchFn, getSortIcon } from './utils';
import useCreateEmailForm from './forms/email-form';
import useImportExcelForm from './forms/import-excel-form';
import { EMPTY_DATA, createFuzzySearchFn } from './utils';
import useImportExcelForm, {
ImportExcelFormData,
} from './forms/import-excel-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),
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]>([
const columnHelper = createColumnHelper<AdminService.ListWhitelistItem>();
const globalFilterFn = createFuzzySearchFn<AdminService.ListWhitelistItem>([
'email',
]);
@ -74,104 +80,114 @@ function AdminWhitelist() {
const queryClient = useQueryClient();
const createEmailForm = useCreateEmailForm();
const editEmailForm = useCreateEmailForm();
const importExcelForm = useImportExcelForm();
const [emailToMakeAction, setEmailToMakeAction] = useState<string | null>(
null,
);
const [itemToMakeAction, setItemToMakeAction] =
useState<AdminService.ListWhitelistItem | null>(null);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [importModalOpen, setImportModalOpen] = useState(false);
const { data: whitelist } = useQuery({
queryKey: ['admin/listWhitelist'],
queryFn: async () => (await listWhitelist())?.data?.data?.white_list,
retry: false,
});
// Reset form when editing a different email
useEffect(() => {
if (emailToMakeAction && editModalOpen) {
createEmailForm.form.setValue('email', emailToMakeAction);
if (itemToMakeAction && editModalOpen) {
editEmailForm.form.setValue('email', itemToMakeAction.email);
}
}, [emailToMakeAction, editModalOpen, createEmailForm.form]);
}, [itemToMakeAction, editModalOpen, editEmailForm.form]);
const { isPending: isCreating, mutateAsync: createEmail } = useMutation({
mutationFn: async (data: { email: string }) => {
/* create email API call */
},
const createWhitelistEntryMutation = useMutation({
mutationFn: (data: { email: string }) => createWhitelistEntry(data.email),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin/whitelist'] });
queryClient.invalidateQueries({ queryKey: ['admin/listWhitelist'] });
setCreateModalOpen(false);
setEmailToMakeAction(null);
createEmailForm.form.reset();
},
onError: (error) => {
console.error('Error creating email:', error);
},
retry: false,
});
const { isPending: isEditing, mutateAsync: updateEmail } = useMutation({
mutationFn: async (data: { email: string }) => {
/* update email API call */
},
const updateWhitelistEntryMutation = useMutation({
mutationFn: (data: { id: number; email: string }) =>
updateWhitelistEntry(data.id, data.email),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin/whitelist'] });
queryClient.invalidateQueries({ queryKey: ['admin/listWhitelist'] });
setEditModalOpen(false);
setEmailToMakeAction(null);
createEmailForm.form.reset();
setItemToMakeAction(null);
editEmailForm.form.reset();
},
});
const { isPending: isDeleting, mutateAsync: deleteEmail } = useMutation({
mutationFn: async (data: { email: string }) => {
/* delete email API call */
},
const deleteWhitelistEntryMutation = useMutation({
mutationFn: (data: { email: string }) => deleteWhitelistEntry(data.email),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin/whitelist'] });
queryClient.invalidateQueries({ queryKey: ['admin/listWhitelist'] });
setDeleteModalOpen(false);
setEmailToMakeAction(null);
setItemToMakeAction(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,
);
},
const importExcelMutation = useMutation({
mutationFn: (data: ImportExcelFormData) =>
importWhitelistFromExcel(data.file),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin/whitelist'] });
queryClient.invalidateQueries({ queryKey: ['admin/listWhitelist'] });
setImportModalOpen(false);
importExcelForm.form.reset();
},
onError: (error) => {
console.error('Error importing Excel:', error);
},
retry: false,
});
const handleExportExcel = () => {
const columnData = (whitelist ?? EMPTY_DATA).map((item) => ({
email: item.email,
}));
const now = new Date();
const YYYY = String(now.getFullYear()).padStart(4, '0');
const MM = String(now.getMonth()).padStart(2, '0');
const dd = String(now.getDate()).padStart(2, '0');
const HH = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
const ss = String(now.getSeconds()).padStart(2, '0');
const worksheet = XLSX.utils.json_to_sheet(columnData);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1');
XLSX.writeFile(workbook, `whitelist_${YYYY}${MM}${dd}${HH}${mm}${ss}.xlsx`);
};
const columnDefs = useMemo(
() => [
columnHelper.accessor('email', {
header: 'Email',
header: t('admin.email'),
enableSorting: false,
}),
columnHelper.accessor('created_by', {
header: 'Created by',
columnHelper.accessor('create_date', {
header: t('admin.createDate'),
}),
columnHelper.accessor('created_at', {
header: 'Created date',
cell: ({ row }) =>
new Date(row.getValue('created_at') as number).toLocaleString(),
columnHelper.accessor('update_date', {
header: t('admin.updateDate'),
}),
columnHelper.display({
id: 'actions',
header: '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
@ -179,7 +195,7 @@ function AdminWhitelist() {
size="icon"
className="border-0 text-text-secondary"
onClick={() => {
setEmailToMakeAction(row.original.email);
setItemToMakeAction(row.original);
setEditModalOpen(true);
}}
>
@ -190,7 +206,7 @@ function AdminWhitelist() {
size="icon"
className="border-0 text-text-secondary"
onClick={() => {
setEmailToMakeAction(row.original.email);
setItemToMakeAction(row.original);
setDeleteModalOpen(true);
}}
>
@ -200,11 +216,11 @@ function AdminWhitelist() {
),
}),
],
[],
[t],
);
const table = useReactTable({
data: PSEUDO_TABLE_ITEMS ?? EMPTY_DATA,
data: whitelist ?? EMPTY_DATA,
columns: columnDefs,
globalFilterFn,
@ -217,7 +233,7 @@ function AdminWhitelist() {
return (
<>
<Card className="h-full border border-border-button bg-transparent rounded-xl overflow-x-hidden overflow-y-auto">
<Card className="!shadow-none 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>
@ -236,6 +252,7 @@ function AdminWhitelist() {
<Button
variant="outline"
className="h-10 px-4 dark:bg-bg-input dark:border-border-button text-text-secondary"
onClick={handleExportExcel}
>
<LucideUpload />
{t('admin.exportAsExcel')}
@ -264,8 +281,8 @@ function AdminWhitelist() {
<Table>
<colgroup>
<col />
<col className="w-[20%]" />
<col className="w-[30%]" />
<col className="w-[25%]" />
<col className="w-[25%]" />
<col className="w-[12rem]" />
</colgroup>
@ -274,12 +291,23 @@ function AdminWhitelist() {
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
{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>
@ -328,7 +356,7 @@ function AdminWhitelist() {
className="p-0 border-border-button"
onAnimationEnd={() => {
if (!deleteModalOpen) {
setEmailToMakeAction(null);
setItemToMakeAction(null);
}
}}
>
@ -341,7 +369,7 @@ function AdminWhitelist() {
{t('admin.deleteWhitelistEmailConfirmation')}
<div className="rounded-lg mt-6 p-4 border border-border-button">
{emailToMakeAction}
{itemToMakeAction?.email}
</div>
</DialogDescription>
</section>
@ -351,7 +379,7 @@ function AdminWhitelist() {
className="px-4 h-10 dark:border-border-button"
variant="outline"
onClick={() => setDeleteModalOpen(false)}
disabled={isDeleting}
disabled={deleteWhitelistEntryMutation.isPending}
>
{t('admin.cancel')}
</Button>
@ -360,10 +388,14 @@ function AdminWhitelist() {
className="px-4 h-10"
variant="destructive"
onClick={() => {
deleteEmail({ email: emailToMakeAction! });
if (itemToMakeAction) {
deleteWhitelistEntryMutation.mutate({
email: itemToMakeAction?.email,
});
}
}}
disabled={isDeleting}
loading={isDeleting}
disabled={deleteWhitelistEntryMutation.isPending}
loading={deleteWhitelistEntryMutation.isPending}
>
{t('admin.delete')}
</LoadingButton>
@ -372,14 +404,15 @@ function AdminWhitelist() {
</Dialog>
{/* Create Email Modal */}
<Dialog
open={createModalOpen}
onOpenChange={() => {
setCreateModalOpen(false);
<Dialog open={createModalOpen} onOpenChange={setCreateModalOpen}>
<DialogContent
className="p-0 border-border-button"
onAnimationEnd={() => {
if (!createModalOpen) {
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>
@ -387,7 +420,7 @@ function AdminWhitelist() {
<section className="px-12 py-4 text-text-secondary">
<createEmailForm.FormComponent
id={createEmailForm.id}
onSubmit={createEmail}
onSubmit={createWhitelistEntryMutation.mutate}
/>
</section>
@ -395,11 +428,8 @@ function AdminWhitelist() {
<Button
className="px-4 h-10 dark:border-border-button"
variant="outline"
onClick={() => {
setCreateModalOpen(false);
createEmailForm.form.reset();
}}
disabled={isCreating}
onClick={() => setCreateModalOpen(false)}
disabled={createWhitelistEntryMutation.isPending}
>
{t('admin.cancel')}
</Button>
@ -409,8 +439,8 @@ function AdminWhitelist() {
type="submit"
className="px-4 h-10"
variant="default"
disabled={isCreating}
loading={isCreating}
disabled={createWhitelistEntryMutation.isPending}
loading={createWhitelistEntryMutation.isPending}
>
{t('admin.confirm')}
</LoadingButton>
@ -419,20 +449,13 @@ function AdminWhitelist() {
</Dialog>
{/* Edit Email Modal */}
<Dialog
open={editModalOpen}
onOpenChange={() => {
setEditModalOpen(false);
setEmailToMakeAction(null);
createEmailForm.form.reset();
}}
>
<Dialog open={editModalOpen} onOpenChange={setEditModalOpen}>
<DialogContent
className="p-0 border-border-button"
onAnimationEnd={() => {
if (!editModalOpen) {
setEmailToMakeAction(null);
createEmailForm.form.reset();
setItemToMakeAction(null);
editEmailForm.form.reset();
}
}}
>
@ -441,9 +464,16 @@ function AdminWhitelist() {
</DialogHeader>
<section className="px-12 py-4 text-text-secondary">
<createEmailForm.FormComponent
id={createEmailForm.id}
onSubmit={updateEmail}
<editEmailForm.FormComponent
id={editEmailForm.id}
onSubmit={(value) => {
if (itemToMakeAction) {
updateWhitelistEntryMutation.mutate({
id: itemToMakeAction.id,
email: value.email,
});
}
}}
/>
</section>
@ -451,23 +481,19 @@ function AdminWhitelist() {
<Button
className="px-4 h-10 dark:border-border-button"
variant="outline"
onClick={() => {
setEditModalOpen(false);
setEmailToMakeAction(null);
createEmailForm.form.reset();
}}
disabled={isEditing}
onClick={() => setEditModalOpen(false)}
disabled={updateWhitelistEntryMutation.isPending}
>
{t('admin.cancel')}
</Button>
<LoadingButton
form={createEmailForm.id}
form={editEmailForm.id}
type="submit"
className="px-4 h-10"
variant="default"
disabled={isEditing}
loading={isEditing}
disabled={updateWhitelistEntryMutation.isPending}
loading={updateWhitelistEntryMutation.isPending}
>
{t('admin.confirm')}
</LoadingButton>
@ -477,7 +503,14 @@ function AdminWhitelist() {
{/* Import Excel Modal */}
<Dialog open={importModalOpen} onOpenChange={setImportModalOpen}>
<DialogContent className="p-0 border-border-button">
<DialogContent
className="p-0 border-border-button"
onAnimationEnd={() => {
if (!importModalOpen) {
importExcelForm.form.reset();
}
}}
>
<DialogHeader className="p-6 border-b border-border-button">
<DialogTitle>{t('admin.importWhitelist')}</DialogTitle>
</DialogHeader>
@ -485,7 +518,7 @@ function AdminWhitelist() {
<section className="px-12 py-4 text-text-secondary">
<importExcelForm.FormComponent
id={importExcelForm.id}
onSubmit={importExcel}
onSubmit={importExcelMutation.mutate}
/>
</section>
@ -493,11 +526,8 @@ function AdminWhitelist() {
<Button
className="px-4 h-10 dark:border-border-button"
variant="outline"
onClick={() => {
setImportModalOpen(false);
importExcelForm.form.reset();
}}
disabled={isImporting}
onClick={() => setImportModalOpen(false)}
disabled={importExcelMutation.isPending}
>
{t('admin.cancel')}
</Button>
@ -507,8 +537,8 @@ function AdminWhitelist() {
type="submit"
className="px-4 h-10"
variant="default"
disabled={isImporting}
loading={isImporting}
disabled={importExcelMutation.isPending}
loading={importExcelMutation.isPending}
>
{t('admin.import')}
</LoadingButton>

View File

@ -128,151 +128,20 @@ const {
adminUpdateUserRole,
adminListResources,
adminListWhitelist,
adminCreateWhitelistEntry,
adminUpdateWhitelistEntry,
adminDeleteWhitelistEntry,
adminImportWhitelist,
} = api;
type ResponseData<D = {}> = {
type ResponseData<D = NonNullable<unknown>> = {
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 AssignRolePermissionsInput = Record<
string,
Partial<PermissionData>
>;
export type RevokeRolePermissionInput = AssignRolePermissionsInput;
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);
@ -363,15 +232,29 @@ export const getUserPermissions = (username: string) =>
export const listResources = () =>
request.get<ResponseData<AdminService.ResourceType>>(adminListResources);
export const whitelistImportFromExcel = (file: File) => {
export const listWhitelist = () =>
request.get<
ResponseData<{
total: number;
white_list: AdminService.ListWhitelistItem[];
}>
>(adminListWhitelist);
export const createWhitelistEntry = (email: string) =>
request.post<ResponseData<never>>(adminCreateWhitelistEntry, { email });
export const updateWhitelistEntry = (id: number, email: string) =>
request.put<ResponseData<never>>(adminUpdateWhitelistEntry(id), { email });
export const deleteWhitelistEntry = (email: string) =>
request.delete<ResponseData<never>>(adminDeleteWhitelistEntry(email));
export const importWhitelistFromExcel = (file: File) => {
const fd = new FormData();
fd.append('file', file);
return request.post<ResponseData<never>>(
'/api/v1/admin/whitelist/import',
fd,
);
return request.post<ResponseData<never>>(adminImportWhitelist, fd);
};
export default {

145
web/src/services/admin.service.d.ts vendored Normal file
View File

@ -0,0 +1,145 @@
declare module 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 AssignRolePermissionsInput = Record<
string,
Partial<PermissionData>
>;
export type RevokeRolePermissionInput = AssignRolePermissionsInput;
export type UserDetailWithPermission = {
user: {
id: string;
username: string;
role: string;
};
role_permissions: Record<string, PermissionData>;
};
export type ResourceType = {
resource_types: string[];
};
export type ListWhitelistItem = {
id: number;
email: string;
create_date: string;
createt_time: number;
update_date: string;
update_time: number;
};
}

View File

@ -239,9 +239,9 @@ export default {
adminGetRolePermissions: (roleName: string) =>
`${ExternalApi}${api_host}/admin/roles/${roleName}/permissions`,
adminAssignRolePermissions: (roleName: string) =>
`${ExternalApi}${api_host}/admin/roles/${roleName}/permissions`,
`${ExternalApi}${api_host}/admin/roles/${roleName}/permission`,
adminRevokeRolePermissions: (roleName: string) =>
`${ExternalApi}${api_host}/admin/roles/${roleName}/permissions/batch`,
`${ExternalApi}${api_host}/admin/roles/${roleName}/permission`,
adminCreateRole: `${ExternalApi}${api_host}/admin/roles`,
adminDeleteRole: (roleName: string) =>
`${ExternalApi}${api_host}/admin/roles/${roleName}`,
@ -253,5 +253,13 @@ export default {
adminGetUserPermissions: (username: string) =>
`${ExternalApi}${api_host}/admin/users/${username}/permissions`,
adminListResources: `${ExternalApi}${api_host}/admin/roles/resources`,
adminListResources: `${ExternalApi}${api_host}/admin/roles/resource`,
adminListWhitelist: `${ExternalApi}${api_host}/admin/whitelist`,
adminCreateWhitelistEntry: `${ExternalApi}${api_host}/admin/whitelist/add`,
adminUpdateWhitelistEntry: (id: number) =>
`${ExternalApi}${api_host}/admin/whitelist/${id}`,
adminDeleteWhitelistEntry: (email: string) =>
`${ExternalApi}${api_host}/admin/whitelist/${email}`,
adminImportWhitelist: `${ExternalApi}${api_host}/admin/whitelist/batch`,
};