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", "umi-request": "^1.4.0",
"unist-util-visit-parents": "^6.0.1", "unist-util-visit-parents": "^6.0.1",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"xlsx": "^0.18.5",
"zod": "^3.23.8", "zod": "^3.23.8",
"zustand": "^4.5.2" "zustand": "^4.5.2"
}, },

View File

@ -1,12 +1,16 @@
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { t } from 'i18next'; import { t } from 'i18next';
import { useIsDarkTheme } from '../theme-provider';
type EmptyProps = { type EmptyProps = {
className?: string; className?: string;
children?: React.ReactNode; children?: React.ReactNode;
}; };
const EmptyIcon = () => ( const EmptyIcon = () => {
const isDarkTheme = useIsDarkTheme();
return (
<svg <svg
width="184" width="184"
height="152" height="152"
@ -18,7 +22,7 @@ const EmptyIcon = () => (
<g transform="translate(24 31.67)"> <g transform="translate(24 31.67)">
<ellipse <ellipse
fillOpacity=".8" fillOpacity=".8"
fill="#F5F5F7" fill={isDarkTheme ? '#28282A' : '#F5F5F7'}
cx="67.797" cx="67.797"
cy="106.89" cy="106.89"
rx="67.797" rx="67.797"
@ -26,7 +30,7 @@ const EmptyIcon = () => (
></ellipse> ></ellipse>
<path <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" 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>
<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" 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>
<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" 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>
<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" 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> ></path>
</g> </g>
<path <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" 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> ></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> <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> <path d="M5.698 5.63H0L2.898.704zM9.259.704h4.985V5.63H9.259z"></path>
</g> </g>
</g> </g>
</svg> </svg>
); );
};
const Empty = (props: EmptyProps) => { const Empty = (props: EmptyProps) => {
const { className, children } = props; const { className, children } = props;

View File

@ -1960,7 +1960,13 @@ Important structured information may include: names, dates, locations, events, k
newRole: 'New Role', newRole: 'New Role',
addNewRole: 'Add new role', addNewRole: 'Add new role',
roleName: 'Role name', roleName: 'Role name',
roleNameRequired: 'Role name is required',
resources: 'Resources', 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({ export default function EnterpriseFeature({
children, children,
}: { }: {
children: () => React.ReactNode; children: React.ReactNode | (() => React.ReactNode);
}) { }) {
return IS_ENTERPRISE return IS_ENTERPRISE
? typeof children === 'function' ? typeof children === 'function'

View File

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

View File

@ -1,4 +1,3 @@
import { Checkbox } from '@/components/ui/checkbox';
import { import {
Form, Form,
FormControl, FormControl,
@ -14,8 +13,8 @@ import { useForm } from 'react-hook-form';
import { Trans, useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
import { z } from 'zod'; import { z } from 'zod';
interface ImportExcelFormData { export interface ImportExcelFormData {
file: FileList; file: File;
overwriteExisting: boolean; overwriteExisting: boolean;
} }
@ -43,6 +42,7 @@ export const ImportExcelForm = ({
<FormField <FormField
control={form.control} control={form.control}
name="file" name="file"
// eslint-disable-next-line @typescript-eslint/no-unused-vars
render={({ field: { onChange, value, ...field } }) => ( render={({ field: { onChange, value, ...field } }) => (
<FormItem> <FormItem>
<FormLabel className="text-sm font-medium"> <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" 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) => { onChange={(e) => {
const files = e.target.files; const files = e.target.files;
onChange(files); onChange(files?.[0]);
}} }}
{...field} {...field}
/> />
@ -66,27 +66,7 @@ export const ImportExcelForm = ({
)} )}
/> />
{/* Overwrite checkbox */} <p className="text-sm text-text-secondary">
<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 <Trans
i18nKey="admin.importFileTips" i18nKey="admin.importFileTips"
components={{ code: <code /> }} components={{ code: <code /> }}
@ -105,21 +85,14 @@ function useImportExcelForm() {
const schema = useMemo(() => { const schema = useMemo(() => {
return z.object({ return z.object({
file: z file: z
.any() .instanceof(File, { message: t('admin.importFileRequired') })
.refine((files) => files && files.length > 0, {
message: t('admin.importFileRequired'),
})
.refine( .refine(
(files) => { (file) => {
if (!files || files.length === 0) return false;
const [file] = files;
return ( return (
file.type === file.type ===
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
// || file.type === 'application/vnd.ms-excel'
file.name.endsWith('.xlsx') file.name.endsWith('.xlsx')
); );
// || file.name.endsWith('.xls');
}, },
{ {
message: t('admin.invalidExcelFile'), 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 { Card, CardContent } from '@/components/ui/card';
import { import {
Form, Form,
@ -16,15 +24,11 @@ import {
TabsList, TabsList,
TabsTrigger, TabsTrigger,
} from '@/components/ui/tabs-underlined'; } 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; name: string;
description: string; description: string;
permissions: Record<string, AdminService.PermissionData>; permissions: Record<string, AdminService.PermissionData>;
@ -36,8 +40,6 @@ interface CreateRoleFormProps {
onSubmit?: (data: CreateRoleFormData) => void; onSubmit?: (data: CreateRoleFormData) => void;
} }
const PERMISSION_TYPES = ['enable', 'read', 'write', 'share'] as const;
export const CreateRoleForm = ({ export const CreateRoleForm = ({
id, id,
form, form,
@ -48,6 +50,7 @@ export const CreateRoleForm = ({
const { data: resourceTypes } = useQuery({ const { data: resourceTypes } = useQuery({
queryKey: ['admin/resourceTypes'], queryKey: ['admin/resourceTypes'],
queryFn: async () => (await listResources()).data.data.resource_types, queryFn: async () => (await listResources()).data.data.resource_types,
retry: false,
}); });
return ( return (
@ -108,9 +111,9 @@ export const CreateRoleForm = ({
<TabsTrigger <TabsTrigger
key={resourceType} key={resourceType}
value={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> </TabsTrigger>
))} ))}
</TabsList> </TabsList>
@ -121,7 +124,7 @@ export const CreateRoleForm = ({
value={resourceType} value={resourceType}
className="space-y-4" className="space-y-4"
> >
<Card className="border-0 bg-bg-card"> <Card className="border-0 bg-bg-card !shadow-none">
<CardContent className="p-6"> <CardContent className="p-6">
<div className="grid grid-cols-4 gap-4"> <div className="grid grid-cols-4 gap-4">
{PERMISSION_TYPES.map((permissionType) => ( {PERMISSION_TYPES.map((permissionType) => (
@ -129,8 +132,8 @@ export const CreateRoleForm = ({
key={permissionType} key={permissionType}
name={`permissions.${resourceType}.${permissionType}`} name={`permissions.${resourceType}.${permissionType}`}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem className="space-y-0 inline-flex items-center gap-2">
<FormLabel className="flex items-center gap-2"> <FormLabel>
{t(`admin.permissionType.${permissionType}`)} {t(`admin.permissionType.${permissionType}`)}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
@ -158,42 +161,46 @@ export const CreateRoleForm = ({
// Export the form validation state for parent component // Export the form validation state for parent component
function useCreateRoleForm(props?: { function useCreateRoleForm(props?: {
defaultValues: Partial<CreateRoleFormData>; defaultValues:
| Partial<CreateRoleFormData>
| (() => Promise<CreateRoleFormData>);
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const id = useId(); const id = useId();
const schema = useMemo(() => { const schema = useMemo(() => {
return z.object({ 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(), description: z.string().optional(),
permissions: z.record( permissions: z.record(
z.string(), z.string(),
z.object({ z.object({
enable: z.boolean(), enable: z.boolean().optional(),
read: z.boolean(), read: z.boolean().optional(),
write: z.boolean(), write: z.boolean().optional(),
share: z.boolean(), share: z.boolean().optional(),
}), }),
), ),
}); });
}, [t]); }, [t]);
const form = useForm<CreateRoleFormData>({ const form = useForm<CreateRoleFormData>({
defaultValues: { defaultValues: formMergeDefaultValues(
{
name: '', name: '',
description: '', description: '',
permissions: {}, permissions: {},
...(props?.defaultValues ?? {}),
}, },
props?.defaultValues,
),
resolver: zodResolver(schema), resolver: zodResolver(schema),
}); });
const FormComponent = useCallback( const FormComponent = useCallback(
(props: Partial<CreateRoleFormProps>) => ( (props: Partial<CreateRoleFormProps>) => (
<CreateRoleForm id="create-role-form" form={form} {...props} /> <CreateRoleForm id={id} form={form} {...props} />
), ),
[form], [id, form],
); );
return { return {

View File

@ -2,7 +2,7 @@ import { Button } from '@/components/ui/button';
import message from '@/components/ui/message'; import message from '@/components/ui/message';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Routes } from '@/routes'; import { Routes } from '@/routes';
import adminService from '@/services/admin-service'; import { logout } from '@/services/admin-service';
import authorizationUtil from '@/utils/authorization-util'; import authorizationUtil from '@/utils/authorization-util';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { import {
@ -60,7 +60,7 @@ const AdminLayout = () => {
const logoutMutation = useMutation({ const logoutMutation = useMutation({
mutationKey: ['adminLogout'], mutationKey: ['adminLogout'],
mutationFn: async () => { mutationFn: async () => {
await adminService.logout(); await logout();
message.success(t('message.logout')); message.success(t('message.logout'));
authorizationUtil.removeAll(); authorizationUtil.removeAll();

View File

@ -2,7 +2,7 @@ import { Card, CardContent } from '@/components/ui/card';
function AdminMonitoring() { function AdminMonitoring() {
return ( 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"> <CardContent className="size-full p-0">
<iframe /> <iframe />
</CardContent> </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 { 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 { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; 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 { Label } from '@/components/ui/label';
import { LoadingButton } from '@/components/ui/loading-button'; import { LoadingButton } from '@/components/ui/loading-button';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
@ -18,71 +29,134 @@ import {
import { LucideEdit3, LucideTrash2, LucideUserPlus } from 'lucide-react'; import { LucideEdit3, LucideTrash2, LucideUserPlus } from 'lucide-react';
import { import {
Dialog, AdminService,
DialogContent, assignRolePermissions,
DialogFooter, createRole,
DialogHeader, deleteRole,
DialogTitle, listResources,
} from '@/components/ui/dialog'; listRolesWithPermission,
revokeRolePermissions,
updateRoleDescription,
} from '@/services/admin-service';
import { listRolesWithPermission } from '@/services/admin-service'; import Empty from '@/components/empty/empty';
import useCreateRoleForm, { CreateRoleFormData } from './forms/role-form';
import useCreateRoleForm from './forms/role-form'; import { PERMISSION_TYPES } 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),
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() { function AdminRoles() {
const { t } = useTranslation(); 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({ const { data: roleList } = useQuery({
queryKey: ['admin/listRolesWithPermission'], 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) => { const updateRoleDescriptionMutation = useMutation({
console.log('New role data:', data); mutationFn: (data: { name: string; description: string }) =>
// TODO: Implement actual role creation logic updateRoleDescription(data.name, data.description),
createRoleForm.form.reset(); onSuccess: () => {
setIsAddRoleModalOpen(false); 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 ( 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"> <ScrollArea className="size-full">
<CardHeader className="space-y-0 flex flex-row justify-between items-center"> <CardHeader className="space-y-0 flex flex-row justify-between items-center">
<CardTitle>{t('admin.roles')}</CardTitle> <CardTitle>{t('admin.roles')}</CardTitle>
<Button <Button
className="h-10 px-4" className="h-10 px-4"
onClick={() => setIsAddRoleModalOpen(true)} onClick={() => setAddRoleModalOpen(true)}
> >
<LucideUserPlus /> <LucideUserPlus />
{t('admin.newRole')} {t('admin.newRole')}
@ -90,20 +164,19 @@ function AdminRoles() {
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
{roleList?.map((role) => { {roleList?.length ? (
const resources = Object.entries(role.permissions); roleList.map((role) => (
return (
<Card <Card
key={role.id} 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"> <CardHeader className="space-y-0 flex flex-row gap-4 items-center border-b border-border-button">
<div className="space-y-1.5"> <div className="space-y-1.5 w-0 flex-1">
<CardTitle className="font-normal text-xl"> <CardTitle className="font-normal text-xl">
{role.role_name} {role.role_name}
</CardTitle> </CardTitle>
<div className="text-sm text-text-secondary">
<div className="text-sm text-text-secondary break-words">
{role.description || ( {role.description || (
<i className="text-muted-foreground"> <i className="text-muted-foreground">
{t('admin.noDescription')} {t('admin.noDescription')}
@ -113,6 +186,11 @@ function AdminRoles() {
<Button <Button
variant="transparent" variant="transparent"
className="ml-2 p-0 border-0 size-[1em] align-middle opacity-0 group-hover:opacity-100 group-focus-within:opacity-100" 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]" /> <LucideEdit3 className="!size-[1em]" />
</Button> </Button>
@ -123,6 +201,11 @@ function AdminRoles() {
variant="ghost" variant="ghost"
size="icon" size="icon"
className="ml-auto opacity-0 group-hover:opacity-100 group-focus-within:opacity-100" className="ml-auto opacity-0 group-hover:opacity-100 group-focus-within:opacity-100"
disabled={deleteRoleMutation.isPending}
onClick={() => {
setDeleteModalOpen(true);
setRoleToMakeAction(role);
}}
> >
<LucideTrash2 /> <LucideTrash2 />
</Button> </Button>
@ -131,82 +214,95 @@ function AdminRoles() {
<CardContent className="p-6"> <CardContent className="p-6">
<Tabs <Tabs
className="h-full flex flex-col" className="h-full flex flex-col"
defaultValue={resources[0]?.[0]} defaultValue={resourceTypes?.[0]}
> >
<TabsList className="p-0 mb-2 gap-4 bg-transparent"> <TabsList className="p-0 mb-2 gap-4 bg-transparent">
{resources.map(([name]) => ( {resourceTypes?.map((resourceName) => (
<TabsTrigger <TabsTrigger
key={name} key={resourceName}
value={name} value={resourceName}
className="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.${name}`)} {t(
`admin.resourceType.${resourceName.toLowerCase()}`,
)}
</TabsTrigger> </TabsTrigger>
))} ))}
</TabsList> </TabsList>
{resources.map(([name, permission]) => ( {resourceTypes?.map((resourceName) => {
<TabsContent key={name} value={name}> const permission =
<div className="flex gap-8"> role.permissions[resourceName.toLowerCase()];
<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"> return (
<Switch <TabsContent key={resourceName} value={resourceName}>
checked={!!permission.read} <Card className="border-0 bg-bg-card !shadow-none">
onCheckedChange={() => {}} <CardContent className="p-6 flex gap-8">
/> {PERMISSION_TYPES.map((permissionType) => (
{t('admin.read')} <Label
</Label> key={permissionType}
className="flex items-center gap-2"
>
{t(`admin.${permissionType}`)}
<Label className="flex items-center gap-2">
<Switch <Switch
checked={!!permission.write} disabled={
onCheckedChange={() => {}} updateRolePermissionsMutation.isPending
}
checked={!!permission?.[permissionType]}
onCheckedChange={(value) =>
updateRolePermissionsMutation.mutate({
name: role.role_name,
resourceName:
resourceName.toLowerCase(),
permissionType,
value,
})
}
/> />
{t('admin.write')}
</Label> </Label>
<Label className="flex items-center gap-2">
<Switch
checked={!!permission.share}
onCheckedChange={() => {}}
/>
{t('admin.share')}
</Label>
</div>
</TabsContent>
))} ))}
</CardContent>
</Card>
</TabsContent>
);
})}
</Tabs> </Tabs>
</CardContent> </CardContent>
</Card> </Card>
); ))
})} ) : (
<Empty className="py-24" />
)}
</CardContent> </CardContent>
</ScrollArea> </ScrollArea>
</Card> </Card>
{/* Add Role Modal */} {/* Add role modal */}
<Dialog open={isAddRoleModalOpen} onOpenChange={setIsAddRoleModalOpen}> <Dialog open={isAddRoleModalOpen} onOpenChange={setAddRoleModalOpen}>
<DialogContent className="max-w-2xl p-0 border-border-button"> <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"> <DialogHeader className="p-6 border-b border-border-button">
<DialogTitle>{t('admin.addNewRole')}</DialogTitle> <DialogTitle>{t('admin.addNewRole')}</DialogTitle>
</DialogHeader> </DialogHeader>
<section className="px-12 py-4"> <section className="px-12 py-4">
<createRoleForm.FormComponent onSubmit={handleAddRole} /> <createRoleForm.FormComponent
onSubmit={createRoleMutation.mutate}
/>
</section> </section>
<DialogFooter className="flex justify-end gap-4 px-12 pt-4 pb-8"> <DialogFooter className="flex justify-end gap-4 px-12 pt-4 pb-8">
<Button <Button
className="px-4 h-10 dark:border-border-button" className="px-4 h-10 dark:border-border-button"
variant="outline" variant="outline"
onClick={() => setIsAddRoleModalOpen(false)} onClick={() => setAddRoleModalOpen(false)}
> >
{t('admin.cancel')} {t('admin.cancel')}
</Button> </Button>
@ -222,6 +318,118 @@ function AdminRoles() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </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)) { if (isPlainObject(content)) {
return ( 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]) => ( {Object.entries<any>(content).map(([key, value]) => (
<div key={key} className="contents"> <div key={key} className="contents">
<dt className="px-3 py-2 bg-bg-card"> <dt className="px-3 py-2 bg-bg-card">

View File

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

View File

@ -41,7 +41,6 @@ import {
getUserDetails, getUserDetails,
listUserAgents, listUserAgents,
listUserDatasets, listUserDatasets,
type AdminService,
} from '@/services/admin-service'; } from '@/services/admin-service';
import EnterpriseFeature from './components/enterprise-feature'; import EnterpriseFeature from './components/enterprise-feature';
@ -323,7 +322,7 @@ function AdminUserDetail() {
</Button> </Button>
</nav> </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"> <CardHeader className="pb-10 border-b dark:border-border-button space-y-8">
<section className="flex items-center gap-4 text-base"> <section className="flex items-center gap-4 text-base">
<Avatar className="justify-center items-center bg-bg-group uppercase"> <Avatar className="justify-center items-center bg-bg-group uppercase">

View File

@ -84,7 +84,6 @@ import {
updateUserPassword, updateUserPassword,
updateUserRole, updateUserRole,
updateUserStatus, updateUserStatus,
type AdminService,
} from '@/services/admin-service'; } from '@/services/admin-service';
import { import {
@ -130,7 +129,7 @@ function AdminUserManagement() {
retry: false, retry: false,
}); });
const { data: usersList, isPending } = useQuery({ const { data: usersList } = useQuery({
queryKey: ['admin/listUsers'], queryKey: ['admin/listUsers'],
queryFn: async () => (await listUsers()).data.data, queryFn: async () => (await listUsers()).data.data,
retry: false, retry: false,
@ -341,13 +340,7 @@ function AdminUserManagement() {
), ),
}), }),
], ],
[ [t, updateUserRoleMutation, roleList, updateUserStatusMutation, navigate],
roleList,
t,
navigate,
updateUserStatusMutation.isPending,
updateUserRoleMutation.isPending,
],
); );
const table = useReactTable({ const table = useReactTable({
@ -364,7 +357,7 @@ function AdminUserManagement() {
return ( 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"> <ScrollArea className="size-full">
<CardHeader className="space-y-0 flex flex-row justify-between items-center"> <CardHeader className="space-y-0 flex flex-row justify-between items-center">
<CardTitle>{t('admin.userManagement')}</CardTitle> <CardTitle>{t('admin.userManagement')}</CardTitle>

View File

@ -4,10 +4,35 @@ import {
Row, Row,
RowData, RowData,
SortDirection, SortDirection,
Table,
TransformFilterValueFn, TransformFilterValueFn,
} from '@tanstack/react-table'; } from '@tanstack/react-table';
import { LucideSortAsc, LucideSortDesc } from 'lucide-react'; 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 { export function parseBooleanish(value: any): boolean {
return typeof value === 'string' return typeof value === 'string'
@ -42,37 +67,6 @@ export function createColumnFilterFn<TData extends RowData>(
return Object.assign(filterFn, options) as FilterFn<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) { export function getSortIcon(sorting: false | SortDirection) {
return { return {
asc: <LucideSortAsc />, asc: <LucideSortAsc />,
@ -80,6 +74,7 @@ export function getSortIcon(sorting: false | SortDirection) {
}[sorting as string]; }[sorting as string];
} }
export const PERMISSION_TYPES = ['enable', 'read', 'write', 'share'] as const;
export const EMPTY_DATA = Object.freeze<any[]>([]) as any[]; export const EMPTY_DATA = Object.freeze<any[]>([]) as any[];
export const IS_ENTERPRISE = export const IS_ENTERPRISE =
process.env.UMI_APP_RAGFLOW_ENTERPRISE === 'RAGFLOW_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 { TableEmpty } from '@/components/table-skeleton';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@ -27,45 +51,27 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table'; } from '@/components/ui/table';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { import {
createColumnHelper, createWhitelistEntry,
flexRender, deleteWhitelistEntry,
getCoreRowModel, importWhitelistFromExcel,
getFilteredRowModel, listWhitelist,
getPaginationRowModel, updateWhitelistEntry,
getSortedRowModel, type AdminService,
useReactTable, } from '@/services/admin-service';
} from '@tanstack/react-table';
import { import { EMPTY_DATA, createFuzzySearchFn, getSortIcon } from './utils';
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 useCreateEmailForm from './forms/email-form';
import useImportExcelForm from './forms/import-excel-form'; import useImportExcelForm, {
import { EMPTY_DATA, createFuzzySearchFn } from './utils'; 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 // #endregion
const columnHelper = createColumnHelper<(typeof PSEUDO_TABLE_ITEMS)[0]>(); const columnHelper = createColumnHelper<AdminService.ListWhitelistItem>();
const globalFilterFn = createFuzzySearchFn<(typeof PSEUDO_TABLE_ITEMS)[0]>([ const globalFilterFn = createFuzzySearchFn<AdminService.ListWhitelistItem>([
'email', 'email',
]); ]);
@ -74,104 +80,114 @@ function AdminWhitelist() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const createEmailForm = useCreateEmailForm(); const createEmailForm = useCreateEmailForm();
const editEmailForm = useCreateEmailForm();
const importExcelForm = useImportExcelForm(); const importExcelForm = useImportExcelForm();
const [emailToMakeAction, setEmailToMakeAction] = useState<string | null>( const [itemToMakeAction, setItemToMakeAction] =
null, useState<AdminService.ListWhitelistItem | null>(null);
);
const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false); const [createModalOpen, setCreateModalOpen] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false); const [editModalOpen, setEditModalOpen] = useState(false);
const [importModalOpen, setImportModalOpen] = 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 // Reset form when editing a different email
useEffect(() => { useEffect(() => {
if (emailToMakeAction && editModalOpen) { if (itemToMakeAction && editModalOpen) {
createEmailForm.form.setValue('email', emailToMakeAction); editEmailForm.form.setValue('email', itemToMakeAction.email);
} }
}, [emailToMakeAction, editModalOpen, createEmailForm.form]); }, [itemToMakeAction, editModalOpen, editEmailForm.form]);
const { isPending: isCreating, mutateAsync: createEmail } = useMutation({ const createWhitelistEntryMutation = useMutation({
mutationFn: async (data: { email: string }) => { mutationFn: (data: { email: string }) => createWhitelistEntry(data.email),
/* create email API call */
},
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin/whitelist'] }); queryClient.invalidateQueries({ queryKey: ['admin/listWhitelist'] });
setCreateModalOpen(false); setCreateModalOpen(false);
setEmailToMakeAction(null);
createEmailForm.form.reset(); createEmailForm.form.reset();
}, },
onError: (error) => { onError: (error) => {
console.error('Error creating email:', error); console.error('Error creating email:', error);
}, },
retry: false,
}); });
const { isPending: isEditing, mutateAsync: updateEmail } = useMutation({ const updateWhitelistEntryMutation = useMutation({
mutationFn: async (data: { email: string }) => { mutationFn: (data: { id: number; email: string }) =>
/* update email API call */ updateWhitelistEntry(data.id, data.email),
},
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin/whitelist'] }); queryClient.invalidateQueries({ queryKey: ['admin/listWhitelist'] });
setEditModalOpen(false); setEditModalOpen(false);
setEmailToMakeAction(null); setItemToMakeAction(null);
createEmailForm.form.reset(); editEmailForm.form.reset();
}, },
}); });
const { isPending: isDeleting, mutateAsync: deleteEmail } = useMutation({ const deleteWhitelistEntryMutation = useMutation({
mutationFn: async (data: { email: string }) => { mutationFn: (data: { email: string }) => deleteWhitelistEntry(data.email),
/* delete email API call */
},
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin/whitelist'] }); queryClient.invalidateQueries({ queryKey: ['admin/listWhitelist'] });
setDeleteModalOpen(false); setDeleteModalOpen(false);
setEmailToMakeAction(null); setItemToMakeAction(null);
}, },
onError: (error) => { onError: (error) => {
console.error('Error deleting email:', error); console.error('Error deleting email:', error);
}, },
}); });
const { isPending: isImporting, mutateAsync: importExcel } = useMutation({ const importExcelMutation = useMutation({
mutationFn: async (data: { mutationFn: (data: ImportExcelFormData) =>
file: FileList; importWhitelistFromExcel(data.file),
overwriteExisting: boolean;
}) => {
/* import Excel API call */
console.log(
'Importing Excel file:',
data.file[0]?.name,
'Overwrite:',
data.overwriteExisting,
);
},
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin/whitelist'] }); queryClient.invalidateQueries({ queryKey: ['admin/listWhitelist'] });
setImportModalOpen(false); setImportModalOpen(false);
importExcelForm.form.reset(); importExcelForm.form.reset();
}, },
onError: (error) => { onError: (error) => {
console.error('Error importing Excel:', 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( const columnDefs = useMemo(
() => [ () => [
columnHelper.accessor('email', { columnHelper.accessor('email', {
header: 'Email', header: t('admin.email'),
enableSorting: false,
}), }),
columnHelper.accessor('created_by', { columnHelper.accessor('create_date', {
header: 'Created by', header: t('admin.createDate'),
}), }),
columnHelper.accessor('created_at', { columnHelper.accessor('update_date', {
header: 'Created date', header: t('admin.updateDate'),
cell: ({ row }) =>
new Date(row.getValue('created_at') as number).toLocaleString(),
}), }),
columnHelper.display({ columnHelper.display({
id: 'actions', id: 'actions',
header: 'Actions', header: t('admin.actions'),
cell: ({ row }) => ( cell: ({ row }) => (
<div className="opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity duration-100"> <div className="opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity duration-100">
<Button <Button
@ -179,7 +195,7 @@ function AdminWhitelist() {
size="icon" size="icon"
className="border-0 text-text-secondary" className="border-0 text-text-secondary"
onClick={() => { onClick={() => {
setEmailToMakeAction(row.original.email); setItemToMakeAction(row.original);
setEditModalOpen(true); setEditModalOpen(true);
}} }}
> >
@ -190,7 +206,7 @@ function AdminWhitelist() {
size="icon" size="icon"
className="border-0 text-text-secondary" className="border-0 text-text-secondary"
onClick={() => { onClick={() => {
setEmailToMakeAction(row.original.email); setItemToMakeAction(row.original);
setDeleteModalOpen(true); setDeleteModalOpen(true);
}} }}
> >
@ -200,11 +216,11 @@ function AdminWhitelist() {
), ),
}), }),
], ],
[], [t],
); );
const table = useReactTable({ const table = useReactTable({
data: PSEUDO_TABLE_ITEMS ?? EMPTY_DATA, data: whitelist ?? EMPTY_DATA,
columns: columnDefs, columns: columnDefs,
globalFilterFn, globalFilterFn,
@ -217,7 +233,7 @@ function AdminWhitelist() {
return ( 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"> <ScrollArea className="size-full">
<CardHeader className="space-y-0 flex flex-row justify-between items-center"> <CardHeader className="space-y-0 flex flex-row justify-between items-center">
<CardTitle>{t('admin.whitelistManagement')}</CardTitle> <CardTitle>{t('admin.whitelistManagement')}</CardTitle>
@ -236,6 +252,7 @@ function AdminWhitelist() {
<Button <Button
variant="outline" variant="outline"
className="h-10 px-4 dark:bg-bg-input dark:border-border-button text-text-secondary" className="h-10 px-4 dark:bg-bg-input dark:border-border-button text-text-secondary"
onClick={handleExportExcel}
> >
<LucideUpload /> <LucideUpload />
{t('admin.exportAsExcel')} {t('admin.exportAsExcel')}
@ -264,8 +281,8 @@ function AdminWhitelist() {
<Table> <Table>
<colgroup> <colgroup>
<col /> <col />
<col className="w-[20%]" /> <col className="w-[25%]" />
<col className="w-[30%]" /> <col className="w-[25%]" />
<col className="w-[12rem]" /> <col className="w-[12rem]" />
</colgroup> </colgroup>
@ -274,12 +291,23 @@ function AdminWhitelist() {
<TableRow key={headerGroup.id}> <TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => ( {headerGroup.headers.map((header) => (
<TableHead key={header.id}> <TableHead key={header.id}>
{header.isPlaceholder {header.isPlaceholder ? null : header.column.getCanSort() ? (
? null <Button
: flexRender( variant="ghost"
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(
header.column.columnDef.header, header.column.columnDef.header,
header.getContext(), header.getContext(),
)} )}
{getSortIcon(header.column.getIsSorted())}
</Button>
) : (
flexRender(
header.column.columnDef.header,
header.getContext(),
)
)}
</TableHead> </TableHead>
))} ))}
</TableRow> </TableRow>
@ -328,7 +356,7 @@ function AdminWhitelist() {
className="p-0 border-border-button" className="p-0 border-border-button"
onAnimationEnd={() => { onAnimationEnd={() => {
if (!deleteModalOpen) { if (!deleteModalOpen) {
setEmailToMakeAction(null); setItemToMakeAction(null);
} }
}} }}
> >
@ -341,7 +369,7 @@ function AdminWhitelist() {
{t('admin.deleteWhitelistEmailConfirmation')} {t('admin.deleteWhitelistEmailConfirmation')}
<div className="rounded-lg mt-6 p-4 border border-border-button"> <div className="rounded-lg mt-6 p-4 border border-border-button">
{emailToMakeAction} {itemToMakeAction?.email}
</div> </div>
</DialogDescription> </DialogDescription>
</section> </section>
@ -351,7 +379,7 @@ function AdminWhitelist() {
className="px-4 h-10 dark:border-border-button" className="px-4 h-10 dark:border-border-button"
variant="outline" variant="outline"
onClick={() => setDeleteModalOpen(false)} onClick={() => setDeleteModalOpen(false)}
disabled={isDeleting} disabled={deleteWhitelistEntryMutation.isPending}
> >
{t('admin.cancel')} {t('admin.cancel')}
</Button> </Button>
@ -360,10 +388,14 @@ function AdminWhitelist() {
className="px-4 h-10" className="px-4 h-10"
variant="destructive" variant="destructive"
onClick={() => { onClick={() => {
deleteEmail({ email: emailToMakeAction! }); if (itemToMakeAction) {
deleteWhitelistEntryMutation.mutate({
email: itemToMakeAction?.email,
});
}
}} }}
disabled={isDeleting} disabled={deleteWhitelistEntryMutation.isPending}
loading={isDeleting} loading={deleteWhitelistEntryMutation.isPending}
> >
{t('admin.delete')} {t('admin.delete')}
</LoadingButton> </LoadingButton>
@ -372,14 +404,15 @@ function AdminWhitelist() {
</Dialog> </Dialog>
{/* Create Email Modal */} {/* Create Email Modal */}
<Dialog <Dialog open={createModalOpen} onOpenChange={setCreateModalOpen}>
open={createModalOpen} <DialogContent
onOpenChange={() => { className="p-0 border-border-button"
setCreateModalOpen(false); onAnimationEnd={() => {
if (!createModalOpen) {
createEmailForm.form.reset(); createEmailForm.form.reset();
}
}} }}
> >
<DialogContent className="p-0 border-border-button">
<DialogHeader className="p-6 border-b border-border-button"> <DialogHeader className="p-6 border-b border-border-button">
<DialogTitle>{t('admin.createEmail')}</DialogTitle> <DialogTitle>{t('admin.createEmail')}</DialogTitle>
</DialogHeader> </DialogHeader>
@ -387,7 +420,7 @@ function AdminWhitelist() {
<section className="px-12 py-4 text-text-secondary"> <section className="px-12 py-4 text-text-secondary">
<createEmailForm.FormComponent <createEmailForm.FormComponent
id={createEmailForm.id} id={createEmailForm.id}
onSubmit={createEmail} onSubmit={createWhitelistEntryMutation.mutate}
/> />
</section> </section>
@ -395,11 +428,8 @@ function AdminWhitelist() {
<Button <Button
className="px-4 h-10 dark:border-border-button" className="px-4 h-10 dark:border-border-button"
variant="outline" variant="outline"
onClick={() => { onClick={() => setCreateModalOpen(false)}
setCreateModalOpen(false); disabled={createWhitelistEntryMutation.isPending}
createEmailForm.form.reset();
}}
disabled={isCreating}
> >
{t('admin.cancel')} {t('admin.cancel')}
</Button> </Button>
@ -409,8 +439,8 @@ function AdminWhitelist() {
type="submit" type="submit"
className="px-4 h-10" className="px-4 h-10"
variant="default" variant="default"
disabled={isCreating} disabled={createWhitelistEntryMutation.isPending}
loading={isCreating} loading={createWhitelistEntryMutation.isPending}
> >
{t('admin.confirm')} {t('admin.confirm')}
</LoadingButton> </LoadingButton>
@ -419,20 +449,13 @@ function AdminWhitelist() {
</Dialog> </Dialog>
{/* Edit Email Modal */} {/* Edit Email Modal */}
<Dialog <Dialog open={editModalOpen} onOpenChange={setEditModalOpen}>
open={editModalOpen}
onOpenChange={() => {
setEditModalOpen(false);
setEmailToMakeAction(null);
createEmailForm.form.reset();
}}
>
<DialogContent <DialogContent
className="p-0 border-border-button" className="p-0 border-border-button"
onAnimationEnd={() => { onAnimationEnd={() => {
if (!editModalOpen) { if (!editModalOpen) {
setEmailToMakeAction(null); setItemToMakeAction(null);
createEmailForm.form.reset(); editEmailForm.form.reset();
} }
}} }}
> >
@ -441,9 +464,16 @@ function AdminWhitelist() {
</DialogHeader> </DialogHeader>
<section className="px-12 py-4 text-text-secondary"> <section className="px-12 py-4 text-text-secondary">
<createEmailForm.FormComponent <editEmailForm.FormComponent
id={createEmailForm.id} id={editEmailForm.id}
onSubmit={updateEmail} onSubmit={(value) => {
if (itemToMakeAction) {
updateWhitelistEntryMutation.mutate({
id: itemToMakeAction.id,
email: value.email,
});
}
}}
/> />
</section> </section>
@ -451,23 +481,19 @@ function AdminWhitelist() {
<Button <Button
className="px-4 h-10 dark:border-border-button" className="px-4 h-10 dark:border-border-button"
variant="outline" variant="outline"
onClick={() => { onClick={() => setEditModalOpen(false)}
setEditModalOpen(false); disabled={updateWhitelistEntryMutation.isPending}
setEmailToMakeAction(null);
createEmailForm.form.reset();
}}
disabled={isEditing}
> >
{t('admin.cancel')} {t('admin.cancel')}
</Button> </Button>
<LoadingButton <LoadingButton
form={createEmailForm.id} form={editEmailForm.id}
type="submit" type="submit"
className="px-4 h-10" className="px-4 h-10"
variant="default" variant="default"
disabled={isEditing} disabled={updateWhitelistEntryMutation.isPending}
loading={isEditing} loading={updateWhitelistEntryMutation.isPending}
> >
{t('admin.confirm')} {t('admin.confirm')}
</LoadingButton> </LoadingButton>
@ -477,7 +503,14 @@ function AdminWhitelist() {
{/* Import Excel Modal */} {/* Import Excel Modal */}
<Dialog open={importModalOpen} onOpenChange={setImportModalOpen}> <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"> <DialogHeader className="p-6 border-b border-border-button">
<DialogTitle>{t('admin.importWhitelist')}</DialogTitle> <DialogTitle>{t('admin.importWhitelist')}</DialogTitle>
</DialogHeader> </DialogHeader>
@ -485,7 +518,7 @@ function AdminWhitelist() {
<section className="px-12 py-4 text-text-secondary"> <section className="px-12 py-4 text-text-secondary">
<importExcelForm.FormComponent <importExcelForm.FormComponent
id={importExcelForm.id} id={importExcelForm.id}
onSubmit={importExcel} onSubmit={importExcelMutation.mutate}
/> />
</section> </section>
@ -493,11 +526,8 @@ function AdminWhitelist() {
<Button <Button
className="px-4 h-10 dark:border-border-button" className="px-4 h-10 dark:border-border-button"
variant="outline" variant="outline"
onClick={() => { onClick={() => setImportModalOpen(false)}
setImportModalOpen(false); disabled={importExcelMutation.isPending}
importExcelForm.form.reset();
}}
disabled={isImporting}
> >
{t('admin.cancel')} {t('admin.cancel')}
</Button> </Button>
@ -507,8 +537,8 @@ function AdminWhitelist() {
type="submit" type="submit"
className="px-4 h-10" className="px-4 h-10"
variant="default" variant="default"
disabled={isImporting} disabled={importExcelMutation.isPending}
loading={isImporting} loading={importExcelMutation.isPending}
> >
{t('admin.import')} {t('admin.import')}
</LoadingButton> </LoadingButton>

View File

@ -128,151 +128,20 @@ const {
adminUpdateUserRole, adminUpdateUserRole,
adminListResources, adminListResources,
adminListWhitelist,
adminCreateWhitelistEntry,
adminUpdateWhitelistEntry,
adminDeleteWhitelistEntry,
adminImportWhitelist,
} = api; } = api;
type ResponseData<D = {}> = { type ResponseData<D = NonNullable<unknown>> = {
code: number; code: number;
message: string; message: string;
data: D; 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 }) => export const login = (params: { email: string; password: string }) =>
request.post<ResponseData<AdminService.LoginData>>(adminLogin, params); request.post<ResponseData<AdminService.LoginData>>(adminLogin, params);
export const logout = () => request.get<ResponseData<boolean>>(adminLogout); export const logout = () => request.get<ResponseData<boolean>>(adminLogout);
@ -363,15 +232,29 @@ export const getUserPermissions = (username: string) =>
export const listResources = () => export const listResources = () =>
request.get<ResponseData<AdminService.ResourceType>>(adminListResources); 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(); const fd = new FormData();
fd.append('file', file); fd.append('file', file);
return request.post<ResponseData<never>>( return request.post<ResponseData<never>>(adminImportWhitelist, fd);
'/api/v1/admin/whitelist/import',
fd,
);
}; };
export default { 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) => adminGetRolePermissions: (roleName: string) =>
`${ExternalApi}${api_host}/admin/roles/${roleName}/permissions`, `${ExternalApi}${api_host}/admin/roles/${roleName}/permissions`,
adminAssignRolePermissions: (roleName: string) => adminAssignRolePermissions: (roleName: string) =>
`${ExternalApi}${api_host}/admin/roles/${roleName}/permissions`, `${ExternalApi}${api_host}/admin/roles/${roleName}/permission`,
adminRevokeRolePermissions: (roleName: string) => adminRevokeRolePermissions: (roleName: string) =>
`${ExternalApi}${api_host}/admin/roles/${roleName}/permissions/batch`, `${ExternalApi}${api_host}/admin/roles/${roleName}/permission`,
adminCreateRole: `${ExternalApi}${api_host}/admin/roles`, adminCreateRole: `${ExternalApi}${api_host}/admin/roles`,
adminDeleteRole: (roleName: string) => adminDeleteRole: (roleName: string) =>
`${ExternalApi}${api_host}/admin/roles/${roleName}`, `${ExternalApi}${api_host}/admin/roles/${roleName}`,
@ -253,5 +253,13 @@ export default {
adminGetUserPermissions: (username: string) => adminGetUserPermissions: (username: string) =>
`${ExternalApi}${api_host}/admin/users/${username}/permissions`, `${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`,
}; };