openvsx-webui-test 0.18.0-dev.0 → 0.18.0-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/lib/extension-registry-service.d.ts +22 -1
  2. package/lib/extension-registry-service.d.ts.map +1 -1
  3. package/lib/extension-registry-service.js +151 -1
  4. package/lib/extension-registry-service.js.map +1 -1
  5. package/lib/extension-registry-types.d.ts +41 -0
  6. package/lib/extension-registry-types.d.ts.map +1 -1
  7. package/lib/extension-registry-types.js +16 -0
  8. package/lib/extension-registry-types.js.map +1 -1
  9. package/lib/pages/admin-dashboard/admin-dashboard.d.ts +3 -0
  10. package/lib/pages/admin-dashboard/admin-dashboard.d.ts.map +1 -1
  11. package/lib/pages/admin-dashboard/admin-dashboard.js +33 -3
  12. package/lib/pages/admin-dashboard/admin-dashboard.js.map +1 -1
  13. package/lib/pages/admin-dashboard/components/data-grid-filter-operators.d.ts +28 -0
  14. package/lib/pages/admin-dashboard/components/data-grid-filter-operators.d.ts.map +1 -0
  15. package/lib/pages/admin-dashboard/components/data-grid-filter-operators.js +93 -0
  16. package/lib/pages/admin-dashboard/components/data-grid-filter-operators.js.map +1 -0
  17. package/lib/pages/admin-dashboard/components/index.d.ts +2 -0
  18. package/lib/pages/admin-dashboard/components/index.d.ts.map +1 -0
  19. package/lib/pages/admin-dashboard/components/index.js +14 -0
  20. package/lib/pages/admin-dashboard/components/index.js.map +1 -0
  21. package/lib/pages/admin-dashboard/customers/customer-form-dialog.d.ts +23 -0
  22. package/lib/pages/admin-dashboard/customers/customer-form-dialog.d.ts.map +1 -0
  23. package/lib/pages/admin-dashboard/customers/customer-form-dialog.js +225 -0
  24. package/lib/pages/admin-dashboard/customers/customer-form-dialog.js.map +1 -0
  25. package/lib/pages/admin-dashboard/customers/customers.d.ts +15 -0
  26. package/lib/pages/admin-dashboard/customers/customers.d.ts.map +1 -0
  27. package/lib/pages/admin-dashboard/customers/customers.js +175 -0
  28. package/lib/pages/admin-dashboard/customers/customers.js.map +1 -0
  29. package/lib/pages/admin-dashboard/customers/delete-customer-dialog.d.ts +23 -0
  30. package/lib/pages/admin-dashboard/customers/delete-customer-dialog.d.ts.map +1 -0
  31. package/lib/pages/admin-dashboard/customers/delete-customer-dialog.js +64 -0
  32. package/lib/pages/admin-dashboard/customers/delete-customer-dialog.js.map +1 -0
  33. package/lib/pages/admin-dashboard/publisher-admin.js +4 -4
  34. package/lib/pages/admin-dashboard/publisher-admin.js.map +1 -1
  35. package/lib/pages/admin-dashboard/tiers/delete-tier-dialog.d.ts +23 -0
  36. package/lib/pages/admin-dashboard/tiers/delete-tier-dialog.d.ts.map +1 -0
  37. package/lib/pages/admin-dashboard/tiers/delete-tier-dialog.js +55 -0
  38. package/lib/pages/admin-dashboard/tiers/delete-tier-dialog.js.map +1 -0
  39. package/lib/pages/admin-dashboard/tiers/tier-form-dialog.d.ts +23 -0
  40. package/lib/pages/admin-dashboard/tiers/tier-form-dialog.d.ts.map +1 -0
  41. package/lib/pages/admin-dashboard/tiers/tier-form-dialog.js +215 -0
  42. package/lib/pages/admin-dashboard/tiers/tier-form-dialog.js.map +1 -0
  43. package/lib/pages/admin-dashboard/tiers/tiers.d.ts +15 -0
  44. package/lib/pages/admin-dashboard/tiers/tiers.d.ts.map +1 -0
  45. package/lib/pages/admin-dashboard/tiers/tiers.js +174 -0
  46. package/lib/pages/admin-dashboard/tiers/tiers.js.map +1 -0
  47. package/lib/pages/admin-dashboard/usage-stats/usage-stats-chart.d.ts +23 -0
  48. package/lib/pages/admin-dashboard/usage-stats/usage-stats-chart.d.ts.map +1 -0
  49. package/lib/pages/admin-dashboard/usage-stats/usage-stats-chart.js +106 -0
  50. package/lib/pages/admin-dashboard/usage-stats/usage-stats-chart.js.map +1 -0
  51. package/lib/pages/admin-dashboard/usage-stats/usage-stats-search.d.ts +26 -0
  52. package/lib/pages/admin-dashboard/usage-stats/usage-stats-search.d.ts.map +1 -0
  53. package/lib/pages/admin-dashboard/usage-stats/usage-stats-search.js +50 -0
  54. package/lib/pages/admin-dashboard/usage-stats/usage-stats-search.js.map +1 -0
  55. package/lib/pages/admin-dashboard/usage-stats/usage-stats-utils.d.ts +14 -0
  56. package/lib/pages/admin-dashboard/usage-stats/usage-stats-utils.d.ts.map +1 -0
  57. package/lib/pages/admin-dashboard/usage-stats/usage-stats-utils.js +16 -0
  58. package/lib/pages/admin-dashboard/usage-stats/usage-stats-utils.js.map +1 -0
  59. package/lib/pages/admin-dashboard/usage-stats/usage-stats.d.ts +15 -0
  60. package/lib/pages/admin-dashboard/usage-stats/usage-stats.d.ts.map +1 -0
  61. package/lib/pages/admin-dashboard/usage-stats/usage-stats.js +103 -0
  62. package/lib/pages/admin-dashboard/usage-stats/usage-stats.js.map +1 -0
  63. package/lib/pages/admin-dashboard/welcome.d.ts.map +1 -1
  64. package/lib/pages/admin-dashboard/welcome.js +4 -1
  65. package/lib/pages/admin-dashboard/welcome.js.map +1 -1
  66. package/lib/server-request.d.ts +1 -1
  67. package/lib/server-request.d.ts.map +1 -1
  68. package/lib/server-request.js +3 -3
  69. package/lib/server-request.js.map +1 -1
  70. package/package.json +8 -1
  71. package/src/extension-registry-service.ts +157 -1
  72. package/src/extension-registry-types.ts +50 -0
  73. package/src/pages/admin-dashboard/admin-dashboard.tsx +45 -2
  74. package/src/pages/admin-dashboard/components/data-grid-filter-operators.tsx +126 -0
  75. package/src/pages/admin-dashboard/components/index.ts +18 -0
  76. package/src/pages/admin-dashboard/customers/customer-form-dialog.tsx +348 -0
  77. package/src/pages/admin-dashboard/customers/customers.tsx +279 -0
  78. package/src/pages/admin-dashboard/customers/delete-customer-dialog.tsx +94 -0
  79. package/src/pages/admin-dashboard/publisher-admin.tsx +4 -4
  80. package/src/pages/admin-dashboard/tiers/delete-tier-dialog.tsx +83 -0
  81. package/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx +372 -0
  82. package/src/pages/admin-dashboard/tiers/tiers.tsx +254 -0
  83. package/src/pages/admin-dashboard/usage-stats/usage-stats-chart.tsx +189 -0
  84. package/src/pages/admin-dashboard/usage-stats/usage-stats-search.tsx +83 -0
  85. package/src/pages/admin-dashboard/usage-stats/usage-stats-utils.ts +16 -0
  86. package/src/pages/admin-dashboard/usage-stats/usage-stats.tsx +127 -0
  87. package/src/pages/admin-dashboard/welcome.tsx +3 -0
  88. package/src/server-request.ts +3 -3
@@ -23,8 +23,16 @@ import HighlightOffIcon from '@mui/icons-material/HighlightOff';
23
23
  import { Welcome } from './welcome';
24
24
  import { PublisherAdmin } from './publisher-admin';
25
25
  import PersonIcon from '@mui/icons-material/Person';
26
+ import PeopleIcon from '@mui/icons-material/People';
26
27
  import { ScanAdmin } from './scan-admin';
27
28
  import SecurityIcon from '@mui/icons-material/Security';
29
+ import StarIcon from '@mui/icons-material/Star';
30
+ import BarChartIcon from '@mui/icons-material/BarChart';
31
+ import { Tiers } from './tiers/tiers';
32
+ import { Customers } from './customers/customers';
33
+ import { UsageStatsView } from './usage-stats/usage-stats';
34
+ import { LoginComponent } from "../../default/login";
35
+ import AccountBoxIcon from "@mui/icons-material/AccountBox";
28
36
 
29
37
  export namespace AdminDashboardRoutes {
30
38
  export const ROOT = 'admin-dashboard';
@@ -33,6 +41,9 @@ export namespace AdminDashboardRoutes {
33
41
  export const EXTENSION_ADMIN = createRoute([ROOT, 'extensions']);
34
42
  export const PUBLISHER_ADMIN = createRoute([ROOT, 'publisher']);
35
43
  export const SCANS_ADMIN = createRoute([ROOT, 'scans']);
44
+ export const TIERS = createRoute([ROOT, 'tiers']);
45
+ export const CUSTOMERS = createRoute([ROOT, 'customers']);
46
+ export const USAGE_STATS = createRoute([ROOT, 'usage']);
36
47
  }
37
48
 
38
49
  const Message: FunctionComponent<{message: string}> = ({ message }) => {
@@ -63,6 +74,9 @@ export const AdminDashboard: FunctionComponent<AdminDashboardProps> = props => {
63
74
  <NavigationItem onOpenRoute={handleOpenRoute} active={currentPage === AdminDashboardRoutes.EXTENSION_ADMIN} label='Extensions' icon={<ExtensionSharpIcon />} route={AdminDashboardRoutes.EXTENSION_ADMIN} />
64
75
  <NavigationItem onOpenRoute={handleOpenRoute} active={currentPage === AdminDashboardRoutes.PUBLISHER_ADMIN} label='Publishers' icon={<PersonIcon />} route={AdminDashboardRoutes.PUBLISHER_ADMIN} />
65
76
  <NavigationItem onOpenRoute={handleOpenRoute} active={currentPage === AdminDashboardRoutes.SCANS_ADMIN} label='Scans' icon={<SecurityIcon />} route={AdminDashboardRoutes.SCANS_ADMIN} />
77
+ <NavigationItem onOpenRoute={handleOpenRoute} active={currentPage === AdminDashboardRoutes.TIERS} label='Tiers' icon={<StarIcon />} route={AdminDashboardRoutes.TIERS} />
78
+ <NavigationItem onOpenRoute={handleOpenRoute} active={currentPage === AdminDashboardRoutes.CUSTOMERS} label='Customers' icon={<PeopleIcon />} route={AdminDashboardRoutes.CUSTOMERS} />
79
+ <NavigationItem onOpenRoute={handleOpenRoute} active={currentPage?.startsWith(AdminDashboardRoutes.USAGE_STATS)} label='Usage Stats' icon={<BarChartIcon />} route={AdminDashboardRoutes.USAGE_STATS} />
66
80
  </Sidepanel>
67
81
  <Box
68
82
  overflow='auto'
@@ -93,6 +107,10 @@ export const AdminDashboard: FunctionComponent<AdminDashboardProps> = props => {
93
107
  <Route path='/extensions' element={<ExtensionAdmin/>} />
94
108
  <Route path='/publisher' element={<PublisherAdmin/>} />
95
109
  <Route path='/scans' element={<ScanAdmin/>} />
110
+ <Route path='/tiers' element={<Tiers/>} />
111
+ <Route path='/customers' element={<Customers/>} />
112
+ <Route path='/usage' element={<UsageStatsView/>} />
113
+ <Route path='/usage/:customer' element={<UsageStatsView/>} />
96
114
  <Route path='*' element={<Welcome/>} />
97
115
  </Routes>
98
116
  </Container>
@@ -101,12 +119,37 @@ export const AdminDashboard: FunctionComponent<AdminDashboardProps> = props => {
101
119
  } else if (user) {
102
120
  content = <Message message='You are not authorized as administrator.'/>;
103
121
  } else if (!props.userLoading && loginProviders) {
104
- content = <Message message='You are not logged in.'/>;
122
+
123
+ content = <Box display='flex' alignItems='center'>
124
+ <Message message='You are not logged in.'/>
125
+ <Box height='fit-content' alignItems='center' display='flex'>
126
+ <LoginComponent
127
+ loginProviders={loginProviders}
128
+ renderButton={(href, onClick) => {
129
+ if (href) {
130
+ return (<IconButton
131
+ href={href}
132
+ title='Log In'
133
+ aria-label='Log In' >
134
+ <AccountBoxIcon />
135
+ </IconButton>);
136
+ } else {
137
+ return (<IconButton
138
+ onClick={onClick}
139
+ title='Log In'
140
+ aria-label='Log In' >
141
+ <AccountBoxIcon />
142
+ </IconButton>);
143
+ }
144
+ }}
145
+ />
146
+ </Box>
147
+ </Box>;
105
148
  }
106
149
 
107
150
  return <>
108
151
  <CssBaseline />
109
- <Box display='flex' height='100vh'>{content}</Box>
152
+ <Box display='flex' height='100vh' justifyContent='center'>{content}</Box>
110
153
  </>;
111
154
  };
112
155
 
@@ -0,0 +1,126 @@
1
+ /*
2
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation.
3
+ *
4
+ * See the NOTICE file(s) distributed with this work for additional
5
+ * information regarding copyright ownership.
6
+ *
7
+ * This program and the accompanying materials are made available under the
8
+ * terms of the Eclipse Public License 2.0 which is available at
9
+ * https://www.eclipse.org/legal/epl-2.0.
10
+ *
11
+ * SPDX-License-Identifier: EPL-2.0
12
+ */
13
+
14
+ import React, { FC } from 'react';
15
+ import { Autocomplete, TextField } from '@mui/material';
16
+ import { GridFilterOperator, GridFilterInputValueProps } from '@mui/x-data-grid';
17
+
18
+ /**
19
+ * Custom multi-select filter input component for DataGrid columns.
20
+ * Renders an Autocomplete with multiple selection support.
21
+ */
22
+ export const MultiSelectFilterInput: FC<GridFilterInputValueProps & { options: string[] }> = ({
23
+ item,
24
+ applyValue,
25
+ options
26
+ }) => {
27
+ const handleChange = (_event: React.SyntheticEvent, newValue: string[]) => {
28
+ applyValue({ ...item, value: newValue });
29
+ };
30
+
31
+ return (
32
+ <Autocomplete
33
+ multiple
34
+ size='small'
35
+ options={options}
36
+ value={(item.value as string[]) || []}
37
+ onChange={handleChange}
38
+ renderInput={(params) => (
39
+ <TextField {...params} variant='standard' placeholder='Filter...' />
40
+ )}
41
+ sx={{ minWidth: 150, mt: 'auto' }}
42
+ />
43
+ );
44
+ };
45
+
46
+ /**
47
+ * Creates filter operators for single-value columns with multi-select capability.
48
+ * Includes "is any of" and "is none of" operators.
49
+ *
50
+ * @param options - Array of possible values to select from
51
+ * @returns Array of GridFilterOperator for use in column definition
52
+ *
53
+ */
54
+ export const createMultiSelectFilterOperators = (options: string[]): GridFilterOperator[] => [
55
+ {
56
+ label: 'is any of',
57
+ value: 'isAnyOf',
58
+ getApplyFilterFn: (filterItem) => {
59
+ if (!filterItem.value || (filterItem.value as string[]).length === 0) {
60
+ return null;
61
+ }
62
+ const filterValues = filterItem.value as string[];
63
+
64
+ return (value) => filterValues.indexOf(value as string) !== -1;
65
+ },
66
+ InputComponent: (props: GridFilterInputValueProps) => (
67
+ <MultiSelectFilterInput {...props} options={options} />
68
+ ),
69
+ },
70
+ {
71
+ label: 'is none of',
72
+ value: 'isNoneOf',
73
+ getApplyFilterFn: (filterItem) => {
74
+ if (!filterItem.value || (filterItem.value as string[]).length === 0) {
75
+ return null;
76
+ }
77
+ const filterValues = filterItem.value as string[];
78
+
79
+ return (value) => filterValues.indexOf(value as string) === -1;
80
+ },
81
+ InputComponent: (props: GridFilterInputValueProps) => (
82
+ <MultiSelectFilterInput {...props} options={options} />
83
+ ),
84
+ },
85
+ ];
86
+
87
+ /**
88
+ * Creates filter operators for array-type columns with multi-select capability.
89
+ * Includes "contains any of" and "contains none of" operators.
90
+ *
91
+ * @param options - Array of possible values to select from
92
+ * @returns Array of GridFilterOperator for use in column definition
93
+ *
94
+ */
95
+ export const createArrayContainsFilterOperators = (options: string[]): GridFilterOperator[] => [
96
+ {
97
+ label: 'contains any of',
98
+ value: 'containsAnyOf',
99
+ getApplyFilterFn: (filterItem) => {
100
+ if (!filterItem.value || (filterItem.value as string[]).length === 0) {
101
+ return null;
102
+ }
103
+ const filterValues = filterItem.value as string[];
104
+
105
+ return (value) => filterValues.some(fv => (value as string[]).indexOf(fv) !== -1);
106
+ },
107
+ InputComponent: (props: GridFilterInputValueProps) => (
108
+ <MultiSelectFilterInput {...props} options={options} />
109
+ ),
110
+ },
111
+ {
112
+ label: 'contains none of',
113
+ value: 'containsNoneOf',
114
+ getApplyFilterFn: (filterItem) => {
115
+ if (!filterItem.value || (filterItem.value as string[]).length === 0) {
116
+ return null;
117
+ }
118
+ const filterValues = filterItem.value as string[];
119
+
120
+ return (value) => !filterValues.some(fv => (value as string[]).indexOf(fv) !== -1);
121
+ },
122
+ InputComponent: (props: GridFilterInputValueProps) => (
123
+ <MultiSelectFilterInput {...props} options={options} />
124
+ ),
125
+ },
126
+ ];
@@ -0,0 +1,18 @@
1
+ /*
2
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation.
3
+ *
4
+ * See the NOTICE file(s) distributed with this work for additional
5
+ * information regarding copyright ownership.
6
+ *
7
+ * This program and the accompanying materials are made available under the
8
+ * terms of the Eclipse Public License 2.0 which is available at
9
+ * https://www.eclipse.org/legal/epl-2.0.
10
+ *
11
+ * SPDX-License-Identifier: EPL-2.0
12
+ */
13
+
14
+ export {
15
+ MultiSelectFilterInput,
16
+ createMultiSelectFilterOperators,
17
+ createArrayContainsFilterOperators
18
+ } from './data-grid-filter-operators';
@@ -0,0 +1,348 @@
1
+
2
+ /******************************************************************************
3
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation.
4
+ *
5
+ * See the NOTICE file(s) distributed with this work for additional
6
+ * information regarding copyright ownership.
7
+ *
8
+ * This program and the accompanying materials are made available under the
9
+ * terms of the Eclipse Public License 2.0 which is available at
10
+ * https://www.eclipse.org/legal/epl-2.0.
11
+ *
12
+ * SPDX-License-Identifier: EPL-2.0
13
+ *****************************************************************************/
14
+
15
+ import React, { FC, useState, useEffect, useRef } from 'react';
16
+ import {
17
+ Dialog,
18
+ DialogTitle,
19
+ DialogContent,
20
+ DialogActions,
21
+ TextField,
22
+ Button,
23
+ FormControl,
24
+ InputLabel,
25
+ Select,
26
+ MenuItem,
27
+ CircularProgress,
28
+ Alert,
29
+ Box,
30
+ Autocomplete,
31
+ FormHelperText,
32
+ styled
33
+ } from '@mui/material';
34
+ import type { SelectChangeEvent } from '@mui/material';
35
+ import { type Customer, EnforcementState, type Tier } from "../../../extension-registry-types";
36
+ import { MainContext } from "../../../context";
37
+ import { handleError } from "../../../utils";
38
+
39
+ interface CustomerFormDialogProps {
40
+ open: boolean;
41
+ customer?: Customer;
42
+ onClose: () => void;
43
+ onSubmit: (formData: Customer) => Promise<void>;
44
+ }
45
+
46
+
47
+ const Code = styled('code')(({ theme }) => ({
48
+ fontFamily: 'source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace',
49
+ backgroundColor: theme.palette.action.hover, // Subtle gray background
50
+ padding: '2px 6px',
51
+ borderRadius: '4px',
52
+ fontSize: '0.9em',
53
+ color: theme.palette.text.primary,
54
+ }));
55
+
56
+ export const CustomerFormDialog: FC<CustomerFormDialogProps> = ({ open, customer, onClose, onSubmit }) => {
57
+ const abortController = useRef<AbortController>(new AbortController());
58
+ const { service } = React.useContext(MainContext);
59
+ const [formData, setFormData] = useState<Customer>({
60
+ name: '',
61
+ tier: undefined,
62
+ state: EnforcementState.ENFORCEMENT,
63
+ cidrBlocks: []
64
+ });
65
+ const [tiers, setTiers] = useState<Tier[]>([]);
66
+ const [loading, setLoading] = useState(false);
67
+ const [errors, setErrors] = useState<Record<string, string>>({});
68
+ const [touched, setTouched] = useState<Record<string, boolean>>({});
69
+
70
+ const loadTiers = async () => {
71
+ try {
72
+ const data = await service.admin.getTiers(abortController.current);
73
+ setTiers(data.tiers);
74
+ } catch (err: any) {
75
+ console.error('Failed to load tiers:', err);
76
+ }
77
+ };
78
+
79
+ useEffect(() => {
80
+ loadTiers();
81
+ return () => abortController.current.abort();
82
+ }, []);
83
+
84
+ useEffect(() => {
85
+ if (customer) {
86
+ setFormData({
87
+ name: customer.name,
88
+ tier: customer.tier,
89
+ state: customer.state,
90
+ cidrBlocks: customer.cidrBlocks
91
+ });
92
+ } else {
93
+ setFormData({
94
+ name: '',
95
+ tier: tiers.length > 0 ? tiers[0] : undefined,
96
+ state: EnforcementState.ENFORCEMENT,
97
+ cidrBlocks: []
98
+ });
99
+ }
100
+ setErrors({});
101
+ setTouched({});
102
+ }, [open, customer, tiers]);
103
+
104
+ const clearFieldError = (fieldName: string) => {
105
+ if (errors[fieldName]) {
106
+ setErrors(prev => {
107
+ const newErrors = { ...prev };
108
+ delete newErrors[fieldName];
109
+ return newErrors;
110
+ });
111
+ }
112
+ };
113
+
114
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement> | SelectChangeEvent) => {
115
+ const { name, value } = e.target;
116
+ clearFieldError(name);
117
+
118
+ if (name === 'tierName') {
119
+ const tier = tiers.find((tier) => tier.name === value);
120
+ setFormData(prev => ({
121
+ ...prev,
122
+ tier: tier,
123
+ }));
124
+ } else {
125
+ setFormData(prev => ({ ...prev, [name]: value }));
126
+ }
127
+ };
128
+
129
+ const handleBlur = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
130
+ const { name } = e.target;
131
+ setTouched(prev => ({ ...prev, [name]: true }));
132
+
133
+ // Validate the specific field on blur
134
+ validateField(name);
135
+ };
136
+
137
+ const fieldValidators: Record<string, () => string | undefined> = {
138
+ name: () => {
139
+ if (formData.name === undefined) {
140
+ return "Customer name is required";
141
+ } else if (formData.name.trim() !== formData.name) {
142
+ return "Customer name must not contain trailing whitespace";
143
+ } else {
144
+ return undefined;
145
+ }
146
+ },
147
+ tierName: () => formData.tier?.name ? undefined : 'Tier selection is required',
148
+ state: () => formData.state ? undefined : 'State is required',
149
+ cidrBlocks: () => {
150
+ if (formData.cidrBlocks && formData.cidrBlocks.length > 0) {
151
+ const invalidEntries = formData.cidrBlocks.filter(cidr => !isValidCIDR(cidr.trim()));
152
+ if (invalidEntries.length > 0) {
153
+ return `Invalid CIDR block(s): ${invalidEntries.join(', ')}`;
154
+ }
155
+ }
156
+ return undefined;
157
+ },
158
+ };
159
+
160
+ const validateField = (fieldName: string): string | undefined => {
161
+ const validator = fieldValidators[fieldName];
162
+ const error = validator?.();
163
+
164
+ if (error) {
165
+ setErrors(prev => ({ ...prev, [fieldName]: error }));
166
+ }
167
+ return error;
168
+ };
169
+
170
+ const isValidCIDR = (cidr: string): boolean => {
171
+ const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/;
172
+ const ipv6Regex = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}(\/\d{1,3})?$/;
173
+ return ipv4Regex.test(cidr) || ipv6Regex.test(cidr);
174
+ };
175
+
176
+ const handleCidrBlocksChange = (event: any, value: string[]) => {
177
+ clearFieldError('cidrBlocks');
178
+
179
+ // Validate all entries
180
+ const invalidEntries = value.filter(cidr => !isValidCIDR(cidr.trim()));
181
+
182
+ if (invalidEntries.length > 0) {
183
+ setTouched(prev => ({ ...prev, cidrBlocks: true }));
184
+ setErrors(prev => ({
185
+ ...prev,
186
+ cidrBlocks: `Invalid CIDR block(s): ${invalidEntries.join(', ')}. Please enter valid IPv4 or IPv6 CIDR notation.`
187
+ }));
188
+ }
189
+
190
+ // Always update the value so the user can see and correct invalid entries
191
+ setFormData(prev => ({
192
+ ...prev,
193
+ cidrBlocks: value.map(cidr => cidr.trim()),
194
+ }));
195
+ };
196
+
197
+ const validateForm = (): boolean => {
198
+ // Mark all fields as touched on submit
199
+ setTouched({
200
+ name: true,
201
+ tierName: true,
202
+ state: true,
203
+ cidrBlocks: true,
204
+ });
205
+
206
+ const newErrors: Record<string, string> = {};
207
+ for (const key of Object.keys(formData)) {
208
+ const error = validateField(key);
209
+ if (error !== undefined) {
210
+ newErrors[key] = error;
211
+ }
212
+ }
213
+
214
+ setErrors(newErrors);
215
+ return Object.keys(newErrors).length === 0;
216
+ };
217
+
218
+ const handleSubmit = async () => {
219
+ if (!validateForm()) {
220
+ return;
221
+ }
222
+
223
+ setLoading(true);
224
+
225
+ try {
226
+ await onSubmit(formData);
227
+ onClose();
228
+ } catch (err: any) {
229
+ setErrors({ submit: handleError(err) });
230
+ } finally {
231
+ setLoading(false);
232
+ }
233
+ };
234
+
235
+ const isEditMode = !!customer;
236
+ const title = isEditMode ? 'Edit Customer' : 'Create New Customer';
237
+
238
+ return (
239
+ <Dialog open={open} onClose={onClose} maxWidth='sm' fullWidth>
240
+ <DialogTitle>{title}</DialogTitle>
241
+ <DialogContent>
242
+ <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 2 }}>
243
+ {errors.submit && (
244
+ <Alert severity='error'>{errors.submit}</Alert>
245
+ )}
246
+
247
+ <TextField
248
+ label='Customer Name'
249
+ name='name'
250
+ value={formData.name}
251
+ onChange={handleChange}
252
+ onBlur={handleBlur}
253
+ fullWidth
254
+ placeholder='e.g., Acme Corp, TechStart Inc'
255
+ required={true}
256
+ disabled={loading}
257
+ error={touched.name && !!errors.name}
258
+ helperText={touched.name && errors.name}
259
+ />
260
+
261
+ <FormControl fullWidth disabled={loading} required={true} error={touched.tierName && !!errors.tierName}>
262
+ <InputLabel>Tier</InputLabel>
263
+ <Select
264
+ name='tierName'
265
+ value={formData.tier?.name ?? ''}
266
+ onChange={handleChange}
267
+ onBlur={(e) => {
268
+ setTouched(prev => ({ ...prev, tierName: true }));
269
+ validateField('tierName');
270
+ }}
271
+ label='Tier'
272
+ >
273
+ {tiers
274
+ .filter(tier => tier.tierType === 'NON_FREE')
275
+ .map(tier => (
276
+ <MenuItem key={tier.name} value={tier.name}>
277
+ {tier.name}
278
+ </MenuItem>
279
+ ))}
280
+ </Select>
281
+ {touched.tierName && errors.tierName && <FormHelperText>{errors.tierName}</FormHelperText>}
282
+ </FormControl>
283
+
284
+ <FormControl fullWidth disabled={loading} required={true} error={touched.state && !!errors.state}>
285
+ <InputLabel>State</InputLabel>
286
+ <Select
287
+ name='state'
288
+ value={formData.state}
289
+ onChange={handleChange}
290
+ onBlur={(e) => {
291
+ setTouched(prev => ({ ...prev, state: true }));
292
+ validateField('state');
293
+ }}
294
+ label='State'
295
+ >
296
+ {Object.keys(EnforcementState).map(key => (
297
+ <MenuItem key={key} value={EnforcementState[key as keyof typeof EnforcementState]}>
298
+ {key}
299
+ </MenuItem>
300
+ ))}
301
+ </Select>
302
+ {touched.state && errors.state && <FormHelperText>{errors.state}</FormHelperText>}
303
+ </FormControl>
304
+
305
+ <Autocomplete
306
+ multiple
307
+ freeSolo
308
+ limitTags={5}
309
+ disabled={loading}
310
+ options={[]}
311
+ value={formData.cidrBlocks || []}
312
+ onChange={handleCidrBlocksChange}
313
+ onBlur={() => {
314
+ setTouched(prev => ({ ...prev, cidrBlocks: true }));
315
+ validateField('cidrBlocks');
316
+ }}
317
+ renderInput={(params) => (
318
+ <TextField
319
+ {...params}
320
+ label='CIDR Blocks'
321
+ placeholder='e.g., 192.168.1.0/24'
322
+ error={touched.cidrBlocks && !!errors.cidrBlocks}
323
+ helperText={(touched.cidrBlocks && errors.cidrBlocks) || (
324
+ <>Enter CIDR blocks and press <Code>Enter</Code> to add each one</>
325
+ )}
326
+ />
327
+ )}
328
+ />
329
+
330
+ </Box>
331
+ </DialogContent>
332
+
333
+ <DialogActions sx={{ p: 2 }}>
334
+ <Button onClick={onClose} disabled={loading}>
335
+ Cancel
336
+ </Button>
337
+ <Button
338
+ onClick={handleSubmit}
339
+ variant='contained'
340
+ disabled={loading || Object.keys(errors).length > 0}
341
+ startIcon={loading ? <CircularProgress size={20} /> : undefined}
342
+ >
343
+ {isEditMode ? 'Update' : 'Create'}
344
+ </Button>
345
+ </DialogActions>
346
+ </Dialog>
347
+ );
348
+ };