Skip to content

Commit 7ec587f

Browse files
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)
1 parent 6853118 commit 7ec587f

File tree

20 files changed

+1038
-1005
lines changed

20 files changed

+1038
-1005
lines changed

web/package-lock.json

Lines changed: 218 additions & 432 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@
114114
"umi-request": "^1.4.0",
115115
"unist-util-visit-parents": "^6.0.1",
116116
"uuid": "^9.0.1",
117+
"xlsx": "^0.18.5",
117118
"zod": "^3.23.8",
118119
"zustand": "^4.5.2"
119120
},

web/src/components/empty/empty.tsx

Lines changed: 52 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,66 @@
11
import { cn } from '@/lib/utils';
22
import { t } from 'i18next';
3+
import { useIsDarkTheme } from '../theme-provider';
34

45
type EmptyProps = {
56
className?: string;
67
children?: React.ReactNode;
78
};
89

9-
const EmptyIcon = () => (
10-
<svg
11-
width="184"
12-
height="152"
13-
viewBox="0 0 184 152"
14-
xmlns="http://www.w3.org/2000/svg"
15-
>
16-
<title>{t('common.noData')}</title>
17-
<g fill="none" fillRule="evenodd">
18-
<g transform="translate(24 31.67)">
19-
<ellipse
20-
fillOpacity=".8"
21-
fill="#F5F5F7"
22-
cx="67.797"
23-
cy="106.89"
24-
rx="67.797"
25-
ry="12.668"
26-
></ellipse>
27-
<path
28-
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"
29-
fill="#AEB8C2"
30-
></path>
31-
<path
32-
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"
33-
fill="url(#linearGradient-1)"
34-
transform="translate(13.56)"
35-
></path>
36-
<path
37-
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"
38-
fill="#F5F5F7"
39-
></path>
10+
const EmptyIcon = () => {
11+
const isDarkTheme = useIsDarkTheme();
12+
13+
return (
14+
<svg
15+
width="184"
16+
height="152"
17+
viewBox="0 0 184 152"
18+
xmlns="http://www.w3.org/2000/svg"
19+
>
20+
<title>{t('common.noData')}</title>
21+
<g fill="none" fillRule="evenodd">
22+
<g transform="translate(24 31.67)">
23+
<ellipse
24+
fillOpacity=".8"
25+
fill={isDarkTheme ? '#28282A' : '#F5F5F7'}
26+
cx="67.797"
27+
cy="106.89"
28+
rx="67.797"
29+
ry="12.668"
30+
></ellipse>
31+
<path
32+
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"
33+
fill={isDarkTheme ? '#736960' : '#AEB8C2'}
34+
></path>
35+
<path
36+
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"
37+
fill="url(#linearGradient-1)"
38+
transform="translate(13.56)"
39+
></path>
40+
<path
41+
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"
42+
fill={isDarkTheme ? '#28282A' : '#F5F5F7'}
43+
></path>
44+
<path
45+
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"
46+
fill={isDarkTheme ? '#45413A' : '#DCE0E6'}
47+
></path>
48+
</g>
4049
<path
41-
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"
42-
fill="#DCE0E6"
50+
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"
51+
fill={isDarkTheme ? '#45413A' : '#DCE0E6'}
4352
></path>
53+
<g
54+
transform="translate(149.65 15.383)"
55+
fill={isDarkTheme ? '#222' : '#FFF'}
56+
>
57+
<ellipse cx="20.654" cy="3.167" rx="2.849" ry="2.815"></ellipse>
58+
<path d="M5.698 5.63H0L2.898.704zM9.259.704h4.985V5.63H9.259z"></path>
59+
</g>
4460
</g>
45-
<path
46-
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"
47-
fill="#DCE0E6"
48-
></path>
49-
<g transform="translate(149.65 15.383)" fill="#FFF">
50-
<ellipse cx="20.654" cy="3.167" rx="2.849" ry="2.815"></ellipse>
51-
<path d="M5.698 5.63H0L2.898.704zM9.259.704h4.985V5.63H9.259z"></path>
52-
</g>
53-
</g>
54-
</svg>
55-
);
61+
</svg>
62+
);
63+
};
5664

5765
const Empty = (props: EmptyProps) => {
5866
const { className, children } = props;

web/src/locales/en.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1960,7 +1960,13 @@ Important structured information may include: names, dates, locations, events, k
19601960
newRole: 'New Role',
19611961
addNewRole: 'Add new role',
19621962
roleName: 'Role name',
1963+
roleNameRequired: 'Role name is required',
19631964
resources: 'Resources',
1965+
1966+
editRoleDescription: 'Edit role description',
1967+
deleteRole: 'Delete role',
1968+
deleteRoleConfirmation:
1969+
'Are you sure you want to delete this role? This action cannot be undone.',
19641970
},
19651971
},
19661972
};

web/src/pages/admin/components/enterprise-feature.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { IS_ENTERPRISE } from '../utils';
33
export default function EnterpriseFeature({
44
children,
55
}: {
6-
children: () => React.ReactNode;
6+
children: React.ReactNode | (() => React.ReactNode);
77
}) {
88
return IS_ENTERPRISE
99
? typeof children === 'function'

web/src/pages/admin/components/theme-switch.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ const ThemeSwitch = forwardRef<
1515
return (
1616
<Root
1717
ref={ref}
18-
className={cn('relative rounded-full')}
18+
className={cn('relative rounded-full', className)}
1919
{...props}
2020
checked={isDark}
2121
onCheckedChange={(value) =>

web/src/pages/admin/forms/import-excel-form.tsx

Lines changed: 7 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { Checkbox } from '@/components/ui/checkbox';
21
import {
32
Form,
43
FormControl,
@@ -14,8 +13,8 @@ import { useForm } from 'react-hook-form';
1413
import { Trans, useTranslation } from 'react-i18next';
1514
import { z } from 'zod';
1615

17-
interface ImportExcelFormData {
18-
file: FileList;
16+
export interface ImportExcelFormData {
17+
file: File;
1918
overwriteExisting: boolean;
2019
}
2120

@@ -43,6 +42,7 @@ export const ImportExcelForm = ({
4342
<FormField
4443
control={form.control}
4544
name="file"
45+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
4646
render={({ field: { onChange, value, ...field } }) => (
4747
<FormItem>
4848
<FormLabel className="text-sm font-medium">
@@ -56,7 +56,7 @@ export const ImportExcelForm = ({
5656
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"
5757
onChange={(e) => {
5858
const files = e.target.files;
59-
onChange(files);
59+
onChange(files?.[0]);
6060
}}
6161
{...field}
6262
/>
@@ -66,27 +66,7 @@ export const ImportExcelForm = ({
6666
)}
6767
/>
6868

69-
{/* Overwrite checkbox */}
70-
<FormField
71-
control={form.control}
72-
name="overwriteExisting"
73-
render={({ field }) => (
74-
<FormItem>
75-
<FormLabel className="flex items-center gap-2 text-sm font-medium">
76-
<FormControl>
77-
<Checkbox
78-
checked={field.value}
79-
onCheckedChange={field.onChange}
80-
/>
81-
</FormControl>
82-
83-
{t('admin.importOverwriteExistingEmails')}
84-
</FormLabel>
85-
</FormItem>
86-
)}
87-
/>
88-
89-
<p className="text-xs text-text-secondary">
69+
<p className="text-sm text-text-secondary">
9070
<Trans
9171
i18nKey="admin.importFileTips"
9272
components={{ code: <code /> }}
@@ -105,21 +85,14 @@ function useImportExcelForm() {
10585
const schema = useMemo(() => {
10686
return z.object({
10787
file: z
108-
.any()
109-
.refine((files) => files && files.length > 0, {
110-
message: t('admin.importFileRequired'),
111-
})
88+
.instanceof(File, { message: t('admin.importFileRequired') })
11289
.refine(
113-
(files) => {
114-
if (!files || files.length === 0) return false;
115-
const [file] = files;
90+
(file) => {
11691
return (
11792
file.type ===
11893
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
119-
// || file.type === 'application/vnd.ms-excel'
12094
file.name.endsWith('.xlsx')
12195
);
122-
// || file.name.endsWith('.xls');
12396
},
12497
{
12598
message: t('admin.invalidExcelFile'),

web/src/pages/admin/forms/role-form.tsx

Lines changed: 36 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
import { useCallback, useId, useMemo } from 'react';
2+
import { useForm } from 'react-hook-form';
3+
import { useTranslation } from 'react-i18next';
4+
5+
import { zodResolver } from '@hookform/resolvers/zod';
6+
import { useQuery } from '@tanstack/react-query';
7+
import { z } from 'zod';
8+
19
import { Card, CardContent } from '@/components/ui/card';
210
import {
311
Form,
@@ -16,15 +24,11 @@ import {
1624
TabsList,
1725
TabsTrigger,
1826
} from '@/components/ui/tabs-underlined';
19-
import { AdminService, listResources } from '@/services/admin-service';
20-
import { zodResolver } from '@hookform/resolvers/zod';
21-
import { useQuery } from '@tanstack/react-query';
22-
import { useCallback, useId, useMemo } from 'react';
23-
import { useForm } from 'react-hook-form';
24-
import { useTranslation } from 'react-i18next';
25-
import { z } from 'zod';
2627

27-
interface CreateRoleFormData {
28+
import { listResources } from '@/services/admin-service';
29+
import { PERMISSION_TYPES, formMergeDefaultValues } from '../utils';
30+
31+
export interface CreateRoleFormData {
2832
name: string;
2933
description: string;
3034
permissions: Record<string, AdminService.PermissionData>;
@@ -36,8 +40,6 @@ interface CreateRoleFormProps {
3640
onSubmit?: (data: CreateRoleFormData) => void;
3741
}
3842

39-
const PERMISSION_TYPES = ['enable', 'read', 'write', 'share'] as const;
40-
4143
export const CreateRoleForm = ({
4244
id,
4345
form,
@@ -48,6 +50,7 @@ export const CreateRoleForm = ({
4850
const { data: resourceTypes } = useQuery({
4951
queryKey: ['admin/resourceTypes'],
5052
queryFn: async () => (await listResources()).data.data.resource_types,
53+
retry: false,
5154
});
5255

5356
return (
@@ -108,9 +111,9 @@ export const CreateRoleForm = ({
108111
<TabsTrigger
109112
key={resourceType}
110113
value={resourceType}
111-
className="text-text-secondary border-border-button dark:data-[state=active]:bg-bg-input"
114+
className="text-text-secondary !border-border-button data-[state=active]:bg-bg-card data-[state=active]:text-text-primary"
112115
>
113-
{t(`admin.resourceType.${resourceType}`)}
116+
{t(`admin.resourceType.${resourceType.toLowerCase()}`)}
114117
</TabsTrigger>
115118
))}
116119
</TabsList>
@@ -121,16 +124,16 @@ export const CreateRoleForm = ({
121124
value={resourceType}
122125
className="space-y-4"
123126
>
124-
<Card className="border-0 bg-bg-card">
127+
<Card className="border-0 bg-bg-card !shadow-none">
125128
<CardContent className="p-6">
126129
<div className="grid grid-cols-4 gap-4">
127130
{PERMISSION_TYPES.map((permissionType) => (
128131
<FormField
129132
key={permissionType}
130133
name={`permissions.${resourceType}.${permissionType}`}
131134
render={({ field }) => (
132-
<FormItem>
133-
<FormLabel className="flex items-center gap-2">
135+
<FormItem className="space-y-0 inline-flex items-center gap-2">
136+
<FormLabel>
134137
{t(`admin.permissionType.${permissionType}`)}
135138
</FormLabel>
136139
<FormControl>
@@ -158,42 +161,46 @@ export const CreateRoleForm = ({
158161

159162
// Export the form validation state for parent component
160163
function useCreateRoleForm(props?: {
161-
defaultValues: Partial<CreateRoleFormData>;
164+
defaultValues:
165+
| Partial<CreateRoleFormData>
166+
| (() => Promise<CreateRoleFormData>);
162167
}) {
163168
const { t } = useTranslation();
164169
const id = useId();
165170

166171
const schema = useMemo(() => {
167172
return z.object({
168-
name: z.string().min(1, { message: 'Role name is required' }),
173+
name: z.string().min(1, { message: t('admin.roleNameRequired') }),
169174
description: z.string().optional(),
170175
permissions: z.record(
171176
z.string(),
172177
z.object({
173-
enable: z.boolean(),
174-
read: z.boolean(),
175-
write: z.boolean(),
176-
share: z.boolean(),
178+
enable: z.boolean().optional(),
179+
read: z.boolean().optional(),
180+
write: z.boolean().optional(),
181+
share: z.boolean().optional(),
177182
}),
178183
),
179184
});
180185
}, [t]);
181186

182187
const form = useForm<CreateRoleFormData>({
183-
defaultValues: {
184-
name: '',
185-
description: '',
186-
permissions: {},
187-
...(props?.defaultValues ?? {}),
188-
},
188+
defaultValues: formMergeDefaultValues(
189+
{
190+
name: '',
191+
description: '',
192+
permissions: {},
193+
},
194+
props?.defaultValues,
195+
),
189196
resolver: zodResolver(schema),
190197
});
191198

192199
const FormComponent = useCallback(
193200
(props: Partial<CreateRoleFormProps>) => (
194-
<CreateRoleForm id="create-role-form" form={form} {...props} />
201+
<CreateRoleForm id={id} form={form} {...props} />
195202
),
196-
[form],
203+
[id, form],
197204
);
198205

199206
return {

web/src/pages/admin/layout.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Button } from '@/components/ui/button';
22
import message from '@/components/ui/message';
33
import { cn } from '@/lib/utils';
44
import { Routes } from '@/routes';
5-
import adminService from '@/services/admin-service';
5+
import { logout } from '@/services/admin-service';
66
import authorizationUtil from '@/utils/authorization-util';
77
import { useMutation } from '@tanstack/react-query';
88
import {
@@ -60,7 +60,7 @@ const AdminLayout = () => {
6060
const logoutMutation = useMutation({
6161
mutationKey: ['adminLogout'],
6262
mutationFn: async () => {
63-
await adminService.logout();
63+
await logout();
6464

6565
message.success(t('message.logout'));
6666
authorizationUtil.removeAll();

0 commit comments

Comments
 (0)