@nestledjs/data-browser 0.1.0

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 (35) hide show
  1. package/.babelrc +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +320 -0
  4. package/eslint.config.mjs +12 -0
  5. package/package.json +44 -0
  6. package/project.json +39 -0
  7. package/src/index.ts +28 -0
  8. package/src/lib/admin-data.spec.tsx +10 -0
  9. package/src/lib/admin-data.tsx +9 -0
  10. package/src/lib/components/filters/DateRangeFilter.tsx +88 -0
  11. package/src/lib/components/filters/NumberRangeFilter.tsx +111 -0
  12. package/src/lib/components/filters/RelationComponents.tsx +176 -0
  13. package/src/lib/components/filters/RelationFilterField.tsx +106 -0
  14. package/src/lib/components/filters/index.ts +5 -0
  15. package/src/lib/components/index.ts +2 -0
  16. package/src/lib/components/shared/AdminBreadcrumbs.tsx +88 -0
  17. package/src/lib/components/shared/AdminErrorStates.tsx +180 -0
  18. package/src/lib/components/shared/AdminStatusDisplay.tsx +292 -0
  19. package/src/lib/components/shared/index.ts +3 -0
  20. package/src/lib/context/AdminDataContext.tsx +74 -0
  21. package/src/lib/hooks/useAdminList.ts +42 -0
  22. package/src/lib/hooks/useClickOutside.ts +21 -0
  23. package/src/lib/hooks/useDebounce.ts +16 -0
  24. package/src/lib/hooks/useRelationData.ts +114 -0
  25. package/src/lib/layouts/AdminDataLayout.tsx +251 -0
  26. package/src/lib/pages/AdminDataCreatePage.tsx +415 -0
  27. package/src/lib/pages/AdminDataEditPage.tsx +777 -0
  28. package/src/lib/pages/AdminDataIndexPage.tsx +50 -0
  29. package/src/lib/pages/AdminDataListPage.tsx +921 -0
  30. package/src/lib/types/index.ts +51 -0
  31. package/src/lib/utils/graphql-utils.ts +538 -0
  32. package/src/lib/utils/secure-storage.ts +305 -0
  33. package/src/lib/utils/string-utils.ts +53 -0
  34. package/tsconfig.json +17 -0
  35. package/tsconfig.lib.json +20 -0
@@ -0,0 +1,305 @@
1
+ // localStorage utilities for admin preferences - designed for easy export/import
2
+ interface AdminConfig {
3
+ version: string
4
+ models: Record<string, {
5
+ visibleColumns?: string[]
6
+ searchFields?: string[]
7
+ sortPreference?: { orderBy: string; orderDirection: string }
8
+ }>
9
+ }
10
+
11
+ const ADMIN_CONFIG_KEY = 'mi-admin-config'
12
+ const ADMIN_CONFIG_VERSION = '1.0'
13
+ const MAX_CONFIG_SIZE = 50000 // 50KB limit to prevent quota exhaustion
14
+
15
+ // Data sanitization utilities
16
+ const sanitizeString = (value: unknown): string => {
17
+ if (typeof value !== 'string') return ''
18
+ // Remove potential XSS vectors while preserving legitimate data
19
+ return value
20
+ .replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '') // Remove script tags
21
+ .replace(/javascript:/gi, '') // Remove javascript: protocols
22
+ .replace(/on\w+\s*=/gi, '') // Remove event handlers
23
+ .trim()
24
+ .substring(0, 1000) // Limit length
25
+ }
26
+
27
+ const sanitizeArray = (value: unknown): string[] => {
28
+ if (!Array.isArray(value)) return []
29
+ return value
30
+ .slice(0, 100) // Limit array size
31
+ .map(item => sanitizeString(item))
32
+ .filter(item => item.length > 0)
33
+ }
34
+
35
+ const sanitizeSortPreference = (value: unknown): { orderBy: string; orderDirection: string } | null => {
36
+ if (!value || typeof value !== 'object') return null
37
+ const obj = value as Record<string, unknown>
38
+
39
+ const orderBy = sanitizeString(obj.orderBy)
40
+ const orderDirection = sanitizeString(obj.orderDirection)
41
+
42
+ // Validate sort direction
43
+ if (!['asc', 'desc'].includes(orderDirection)) return null
44
+ // Validate field name (alphanumeric + underscore only)
45
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(orderBy)) return null
46
+
47
+ return { orderBy, orderDirection }
48
+ }
49
+
50
+ // Enhanced validation with security checks
51
+ const validateAdminConfig = (config: unknown): config is AdminConfig => {
52
+ if (!config || typeof config !== 'object') return false
53
+
54
+ const configObj = config as Record<string, unknown>
55
+
56
+ // Validate version
57
+ if (configObj.version !== ADMIN_CONFIG_VERSION) return false
58
+
59
+ // Validate models object
60
+ if (!configObj.models || typeof configObj.models !== 'object') return false
61
+
62
+ const models = configObj.models as Record<string, unknown>
63
+
64
+ // Validate each model configuration
65
+ for (const [modelName, modelConfig] of Object.entries(models)) {
66
+ // Sanitize model name
67
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(modelName)) return false
68
+
69
+ if (modelConfig && typeof modelConfig === 'object') {
70
+ const config = modelConfig as Record<string, unknown>
71
+
72
+ // Validate visible columns if present
73
+ if (config.visibleColumns !== undefined && !Array.isArray(config.visibleColumns)) return false
74
+
75
+ // Validate sort preference if present
76
+ if (config.sortPreference !== undefined) {
77
+ const sortPref = sanitizeSortPreference(config.sortPreference)
78
+ if (config.sortPreference !== null && sortPref === null) return false
79
+ }
80
+
81
+ // Validate search fields if present
82
+ if (config.searchFields !== undefined && !Array.isArray(config.searchFields)) return false
83
+ }
84
+ }
85
+
86
+ return true
87
+ }
88
+
89
+ export const SecureAdminLocalStorage = {
90
+ // Get the full admin config with validation
91
+ getConfig: (): AdminConfig => {
92
+ try {
93
+ const stored = localStorage.getItem(ADMIN_CONFIG_KEY)
94
+ if (!stored) {
95
+ return { version: ADMIN_CONFIG_VERSION, models: {} }
96
+ }
97
+
98
+ // Check size limit
99
+ if (stored.length > MAX_CONFIG_SIZE) {
100
+ console.warn('[AdminLocalStorage] Config exceeds size limit, resetting')
101
+ localStorage.removeItem(ADMIN_CONFIG_KEY)
102
+ return { version: ADMIN_CONFIG_VERSION, models: {} }
103
+ }
104
+
105
+ const parsed = JSON.parse(stored)
106
+
107
+ if (!validateAdminConfig(parsed)) {
108
+ console.warn('[AdminLocalStorage] Invalid config detected, resetting')
109
+ localStorage.removeItem(ADMIN_CONFIG_KEY)
110
+ return { version: ADMIN_CONFIG_VERSION, models: {} }
111
+ }
112
+
113
+ return parsed
114
+
115
+ } catch (error) {
116
+ console.warn('[AdminLocalStorage] Failed to load config:', error)
117
+ // Clear potentially corrupted data
118
+ try {
119
+ localStorage.removeItem(ADMIN_CONFIG_KEY)
120
+ } catch {
121
+ // Ignore cleanup errors
122
+ }
123
+ return { version: ADMIN_CONFIG_VERSION, models: {} }
124
+ }
125
+ },
126
+
127
+ // Save the full admin config with validation
128
+ setConfig: (config: AdminConfig): boolean => {
129
+ try {
130
+ // Validate input
131
+ if (!validateAdminConfig(config)) {
132
+ console.warn('[AdminLocalStorage] Invalid config provided')
133
+ return false
134
+ }
135
+
136
+ const serialized = JSON.stringify(config)
137
+
138
+ // Check size limit
139
+ if (serialized.length > MAX_CONFIG_SIZE) {
140
+ console.warn('[AdminLocalStorage] Config too large to store')
141
+ return false
142
+ }
143
+
144
+ localStorage.setItem(ADMIN_CONFIG_KEY, serialized)
145
+ return true
146
+
147
+ } catch (error) {
148
+ console.warn('[AdminLocalStorage] Failed to save config:', error)
149
+ return false
150
+ }
151
+ },
152
+
153
+ // Get visible columns for a specific model with sanitization
154
+ getColumnVisibility: (modelName: string): string[] | null => {
155
+ const sanitizedModelName = sanitizeString(modelName)
156
+ if (!sanitizedModelName) return null
157
+
158
+ const config = SecureAdminLocalStorage.getConfig()
159
+ const columns = config.models[sanitizedModelName]?.visibleColumns
160
+ return columns ? sanitizeArray(columns) : null
161
+ },
162
+
163
+ // Set visible columns for a specific model with validation
164
+ setColumnVisibility: (modelName: string, visibleColumns: string[]): boolean => {
165
+ const sanitizedModelName = sanitizeString(modelName)
166
+ const sanitizedColumns = sanitizeArray(visibleColumns)
167
+
168
+ // Allow empty arrays - it's a valid preference (no columns selected)
169
+ if (!sanitizedModelName) return false
170
+
171
+ const config = SecureAdminLocalStorage.getConfig()
172
+ if (!config.models[sanitizedModelName]) {
173
+ config.models[sanitizedModelName] = {}
174
+ }
175
+ config.models[sanitizedModelName].visibleColumns = sanitizedColumns
176
+ return SecureAdminLocalStorage.setConfig(config)
177
+ },
178
+
179
+ // Get sort preference for a specific model with validation
180
+ getSortPreference: (modelName: string): { orderBy: string; orderDirection: string } | null => {
181
+ const sanitizedModelName = sanitizeString(modelName)
182
+ if (!sanitizedModelName) return null
183
+
184
+ const config = SecureAdminLocalStorage.getConfig()
185
+ const sortPref = config.models[sanitizedModelName]?.sortPreference
186
+ return sortPref ? sanitizeSortPreference(sortPref) : null
187
+ },
188
+
189
+ // Set sort preference for a specific model with validation
190
+ setSortPreference: (modelName: string, sortPreference: { orderBy: string; orderDirection: string }): boolean => {
191
+ const sanitizedModelName = sanitizeString(modelName)
192
+ const sanitizedSortPref = sanitizeSortPreference(sortPreference)
193
+
194
+ if (!sanitizedModelName || !sanitizedSortPref) return false
195
+
196
+ const config = SecureAdminLocalStorage.getConfig()
197
+ if (!config.models[sanitizedModelName]) {
198
+ config.models[sanitizedModelName] = {}
199
+ }
200
+ config.models[sanitizedModelName].sortPreference = sanitizedSortPref
201
+ return SecureAdminLocalStorage.setConfig(config)
202
+ },
203
+
204
+ // Get search fields for a specific model with sanitization
205
+ getSearchFields: (modelName: string): string[] | null => {
206
+ const sanitizedModelName = sanitizeString(modelName)
207
+ if (!sanitizedModelName) return null
208
+
209
+ const config = SecureAdminLocalStorage.getConfig()
210
+ const searchFields = config.models[sanitizedModelName]?.searchFields
211
+ return searchFields ? sanitizeArray(searchFields) : null
212
+ },
213
+
214
+ // Set search fields for a specific model with validation
215
+ setSearchFields: (modelName: string, searchFields: string[]): boolean => {
216
+ const sanitizedModelName = sanitizeString(modelName)
217
+ const sanitizedFields = sanitizeArray(searchFields)
218
+
219
+ // Allow empty arrays - it's a valid preference (no search fields selected)
220
+ if (!sanitizedModelName) return false
221
+
222
+ const config = SecureAdminLocalStorage.getConfig()
223
+ if (!config.models[sanitizedModelName]) {
224
+ config.models[sanitizedModelName] = {}
225
+ }
226
+ config.models[sanitizedModelName].searchFields = sanitizedFields
227
+ return SecureAdminLocalStorage.setConfig(config)
228
+ },
229
+
230
+ // Export config as JSON string with validation
231
+ exportConfig: (): string | null => {
232
+ try {
233
+ const config = SecureAdminLocalStorage.getConfig()
234
+ // Double-check validation before export
235
+ if (!validateAdminConfig(config)) {
236
+ console.warn('[AdminLocalStorage] Cannot export invalid config')
237
+ return null
238
+ }
239
+ return JSON.stringify(config, null, 2)
240
+ } catch (error) {
241
+ console.warn('[AdminLocalStorage] Failed to export config:', error)
242
+ return null
243
+ }
244
+ },
245
+
246
+ // Import config from JSON string with comprehensive security validation
247
+ importConfig: (configJson: string): boolean => {
248
+ try {
249
+ // Sanitize input string
250
+ const sanitizedJson = sanitizeString(configJson)
251
+ if (!sanitizedJson || sanitizedJson.length > MAX_CONFIG_SIZE) {
252
+ console.warn('[AdminLocalStorage] Invalid or oversized config JSON')
253
+ return false
254
+ }
255
+
256
+ const config = JSON.parse(sanitizedJson) as AdminConfig
257
+
258
+ // Comprehensive validation with security checks
259
+ if (!validateAdminConfig(config)) {
260
+ console.warn('[AdminLocalStorage] Failed config validation during import')
261
+ return false
262
+ }
263
+
264
+ // Additional security: Check for suspicious patterns
265
+ const serialized = JSON.stringify(config)
266
+ const suspiciousPatterns = [
267
+ /<script/i,
268
+ /javascript:/i,
269
+ /on\w+\s*=/i,
270
+ /eval\s*\(/i,
271
+ /function\s*\(/i
272
+ ]
273
+
274
+ for (const pattern of suspiciousPatterns) {
275
+ if (pattern.test(serialized)) {
276
+ console.warn('[AdminLocalStorage] Suspicious content detected in config')
277
+ return false
278
+ }
279
+ }
280
+
281
+ return SecureAdminLocalStorage.setConfig(config)
282
+
283
+ } catch (error) {
284
+ console.warn('[AdminLocalStorage] Failed to import config:', error)
285
+ return false
286
+ }
287
+ },
288
+
289
+ // Clear all stored data (for security/privacy)
290
+ clearConfig: (): boolean => {
291
+ try {
292
+ localStorage.removeItem(ADMIN_CONFIG_KEY)
293
+ return true
294
+ } catch (error) {
295
+ console.warn('[AdminLocalStorage] Failed to clear config:', error)
296
+ return false
297
+ }
298
+ }
299
+ }
300
+
301
+ // Use the secure version for backwards compatibility
302
+ export const AdminLocalStorage = SecureAdminLocalStorage
303
+
304
+ // Export types
305
+ export type { AdminConfig }
@@ -0,0 +1,53 @@
1
+ // Helper to convert PascalCase or camelCase to kebab-case
2
+ export function kebabCase(name: string): string {
3
+ return name
4
+ .replace(/([a-z])([A-Z])/g, '$1-$2')
5
+ .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
6
+ .toLowerCase()
7
+ }
8
+
9
+ // Helper to convert PascalCase or camelCase to spaced words
10
+ export function spacedWords(name: string): string {
11
+ return name.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/([A-Z])([A-Z][a-z])/g, '$1 $2')
12
+ }
13
+
14
+ // Helper to format field name for display
15
+ export function formatFieldName(fieldName: string): string {
16
+ return fieldName
17
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
18
+ .replace(/^./, str => str.toUpperCase())
19
+ }
20
+
21
+ // Smart normalization function for GraphQL document names
22
+ export function normalizeModelNameForDocument(modelName: string): string {
23
+ // If it's all uppercase (likely an acronym), convert to proper case
24
+ if (modelName === modelName.toUpperCase() && modelName.length > 1) {
25
+ // For acronyms, only capitalize the first letter for document names
26
+ return modelName.charAt(0).toUpperCase() + modelName.slice(1).toLowerCase()
27
+ }
28
+
29
+ // For normal PascalCase names, return as-is
30
+ return modelName
31
+ }
32
+
33
+ // Utility function for generating display names
34
+ export function getItemDisplayName(item: any): string {
35
+ if (item.name) return item.name
36
+ if (item.title) return item.title
37
+ if (item.firstName && item.lastName) return `${item.firstName} ${item.lastName}`
38
+ if (item.firstName) return item.firstName
39
+ if (item.email) return item.email
40
+ return item.id
41
+ }
42
+
43
+ // Utility function for getting smart search fields
44
+ export function getSmartSearchFields(availableFields: string[]): string[] {
45
+ const primaryFields = ['name', 'title', 'email', 'firstName', 'lastName', 'subject']
46
+ const primaryMatches = primaryFields.filter(field => availableFields.includes(field))
47
+
48
+ if (primaryMatches.length >= 1) {
49
+ return primaryMatches.slice(0, 2)
50
+ }
51
+
52
+ return availableFields.slice(0, Math.min(2, availableFields.length))
53
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "jsx": "react-jsx",
4
+ "allowJs": false,
5
+ "esModuleInterop": false,
6
+ "allowSyntheticDefaultImports": true,
7
+ "strict": true
8
+ },
9
+ "files": [],
10
+ "include": [],
11
+ "references": [
12
+ {
13
+ "path": "./tsconfig.lib.json"
14
+ }
15
+ ],
16
+ "extends": "../../tsconfig.base.json"
17
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "../../dist/out-tsc",
5
+ "declaration": true,
6
+ "types": ["node", "@nx/react/typings/cssmodule.d.ts", "@nx/react/typings/image.d.ts"]
7
+ },
8
+ "exclude": [
9
+ "jest.config.ts",
10
+ "src/**/*.spec.ts",
11
+ "src/**/*.test.ts",
12
+ "src/**/*.spec.tsx",
13
+ "src/**/*.test.tsx",
14
+ "src/**/*.spec.js",
15
+ "src/**/*.test.js",
16
+ "src/**/*.spec.jsx",
17
+ "src/**/*.test.jsx"
18
+ ],
19
+ "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"]
20
+ }