ragflow/web/src/pages/admin/users.tsx
Jimmy Ben Klieve 7ec587fa9e
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)
2025-11-03 09:52:23 +08:00

695 lines
22 KiB
TypeScript

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,
} 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,
enabled: IS_ENTERPRISE,
retry: false,
});
const { data: usersList } = useQuery({
queryKey: ['admin/listUsers'],
queryFn: async () => (await listUsers()).data.data,
retry: false,
});
// Delete user mutation
const deleteUserMutation = useMutation({
mutationFn: deleteUser,
onSuccess: () => {
// message.success(t('admin.userDeletedSuccessfully'));
queryClient.invalidateQueries({ queryKey: ['admin/listUsers'] });
setDeleteModalOpen(false);
setUserToMakeAction(null);
},
retry: false,
});
// Change password mutation
const changePasswordMutation = useMutation({
mutationFn: ({ email, password }: { email: string; password: string }) =>
updateUserPassword(email, rsaPsw(password) as string),
onSuccess: () => {
// message.success(t('admin.passwordChangedSuccessfully'));
setPasswordModalOpen(false);
setUserToMakeAction(null);
},
retry: false,
});
// Update user role mutation
const updateUserRoleMutation = useMutation({
mutationFn: ({ email, role }: { email: string; role: string }) =>
updateUserRole(email, role),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin/listUsers'] });
},
retry: false,
});
// Create user mutation
const createUserMutation = useMutation({
mutationFn: async ({
email,
password,
role,
}: {
email: string;
password: string;
role?: string;
}) => {
await createUser(email, rsaPsw(password) as string);
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();
},
retry: false,
});
// 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'] });
},
retry: false,
});
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>
),
}),
],
[t, updateUserRoleMutation, roleList, updateUserStatusMutation, navigate],
);
const table = useReactTable({
data: usersList ?? EMPTY_DATA,
columns: columnDefs,
globalFilterFn,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
<>
<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>
<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={() =>
userToMakeAction &&
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"
onAnimationEnd={() => {
if (!passwordModalOpen) {
changePasswordForm.form.reset();
}
}}
>
<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: newPassword,
});
}
}}
/>
</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={createUserMutation.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={() => {
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;