Skip to content

Commit 2e4e387

Browse files
ril3yclaude
andcommitted
fix(frontend): unwrap ResponseSchema in service methods to prevent .map() errors
Multiple frontend services returned the full API ResponseSchema wrapper ({status, message, data}) instead of extracting the inner .data payload. This caused TypeError: y.map is not a function when components tried to iterate over what they expected to be arrays. Fixed services: apiKey, ai, settings, parts, dynamic-supplier Also fixed CredentialEditor.tsx build errors (added 'file' field_type, fixed .split() on potential array type). Added 29 regression tests for apiKey.service.ts to catch this pattern. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 47474c2 commit 2e4e387

8 files changed

Lines changed: 797 additions & 228 deletions

File tree

MakerMatrix/frontend/src/__tests__/services/apiKey.service.test.ts

Lines changed: 531 additions & 0 deletions
Large diffs are not rendered by default.

MakerMatrix/frontend/src/components/suppliers/CredentialEditor.tsx

Lines changed: 86 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*/
77

88
import React, { useState, useEffect, useMemo, useRef } from 'react'
9-
import { Eye, EyeOff, TestTube, Save, HelpCircle, CheckCircle, XCircle } from 'lucide-react'
9+
import { Eye, EyeOff, TestTube, Save, HelpCircle, CheckCircle, XCircle, Upload } from 'lucide-react'
1010
import { useFormWithValidation } from '@/hooks/useFormWithValidation'
1111
import {
1212
createCredentialFormSchema,
@@ -76,9 +76,13 @@ export const CredentialEditor: React.FC<CredentialEditorProps> = ({
7676
},
7777
})
7878

79-
// Initialize credentials when schema changes or credential status loads
79+
// Track if initialization has already happened
80+
const initializedRef = useRef(false)
81+
82+
// Initialize credentials when schema changes or credential status loads (only once)
8083
useEffect(() => {
8184
if (credentialSchema.length === 0) return
85+
if (initializedRef.current) return // Don't re-initialize
8286

8387
const initializeCredentials = async () => {
8488
const credentials: CredentialFormData = {}
@@ -121,6 +125,8 @@ export const CredentialEditor: React.FC<CredentialEditorProps> = ({
121125
Object.entries(credentials).forEach(([key, value]) => {
122126
form.setValue(key as keyof CredentialFormData, value)
123127
})
128+
129+
initializedRef.current = true
124130
}
125131

126132
initializeCredentials()
@@ -280,6 +286,7 @@ export const CredentialEditor: React.FC<CredentialEditorProps> = ({
280286
<div className="space-y-3">
281287
{credentialSchema.map((field) => {
282288
const isPassword = field.field_type === 'password'
289+
const isFile = field.field_type === 'file'
283290
const currentValue = form.watch(field.name) || ''
284291
const isConfigured = credentialStatus?.configured_fields?.includes(field.name) || false
285292
const shouldShow = showValues[field.name]
@@ -323,25 +330,83 @@ export const CredentialEditor: React.FC<CredentialEditorProps> = ({
323330
</div>
324331

325332
<div className="relative">
326-
<FormInput
327-
label={field.label}
328-
type={shouldShow ? 'text' : 'password'}
329-
placeholder={field.placeholder || `Enter ${field.label.toLowerCase()}`}
330-
registration={form.register(field.name)}
331-
error={form.getFieldError(field.name)}
332-
disabled={loading}
333-
className="pr-10"
334-
/>
335-
336-
{(isPassword || hasValue) && (
337-
<button
338-
type="button"
339-
onClick={() => toggleShowValue(field.name)}
340-
className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 z-10"
341-
title="Toggle visibility"
342-
>
343-
{shouldShow ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
344-
</button>
333+
{/* File upload field */}
334+
{isFile ? (
335+
<div className="flex items-center space-x-2">
336+
<label className="cursor-pointer inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 text-sm font-medium rounded-md text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600">
337+
<Upload className="w-4 h-4 mr-2" />
338+
Upload File
339+
<input
340+
type="file"
341+
className="hidden"
342+
accept=".pfx,.p12,.pem,.crt,.key"
343+
onChange={async (e) => {
344+
if (e.target.files && e.target.files[0]) {
345+
const file = e.target.files[0]
346+
try {
347+
// Upload the file to the backend
348+
const formData = new FormData()
349+
formData.append('file', file)
350+
const response = await fetch(
351+
`/api/suppliers/${supplierName.toLowerCase()}/file-upload`,
352+
{
353+
method: 'POST',
354+
headers: {
355+
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
356+
},
357+
body: formData,
358+
}
359+
)
360+
if (response.ok) {
361+
const data = await response.json()
362+
const filePath = data.data?.file_path || data.file_path
363+
form.setValue(field.name as keyof CredentialFormData, filePath)
364+
toast.success(`File "${file.name}" uploaded successfully`)
365+
} else {
366+
toast.error('Failed to upload file')
367+
}
368+
} catch (error) {
369+
console.error('File upload error:', error)
370+
toast.error('Failed to upload file')
371+
}
372+
}
373+
}}
374+
disabled={loading}
375+
/>
376+
</label>
377+
{hasValue && (
378+
<div className="flex items-center text-green-600 dark:text-green-400">
379+
<CheckCircle className="w-4 h-4 mr-1" />
380+
<span className="text-sm">
381+
{String(currentValue).split('/').pop() || String(currentValue).split('\\').pop() || 'Uploaded'}
382+
</span>
383+
</div>
384+
)}
385+
</div>
386+
) : (
387+
/* Text/Password fields */
388+
<>
389+
<FormInput
390+
label={field.label}
391+
type={shouldShow ? 'text' : 'password'}
392+
placeholder={field.placeholder || `Enter ${field.label.toLowerCase()}`}
393+
registration={form.register(field.name)}
394+
error={form.getFieldError(field.name)}
395+
disabled={loading}
396+
className="pr-10"
397+
/>
398+
399+
{(isPassword || hasValue) && (
400+
<button
401+
type="button"
402+
onClick={() => toggleShowValue(field.name)}
403+
className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 z-10"
404+
title="Toggle visibility"
405+
>
406+
{shouldShow ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
407+
</button>
408+
)}
409+
</>
345410
)}
346411
</div>
347412
</div>

MakerMatrix/frontend/src/schemas/credentials.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { z } from 'zod'
44
export const credentialFieldSchema = z.object({
55
name: z.string().min(1, 'Field name is required'),
66
label: z.string().min(1, 'Field label is required'),
7-
field_type: z.enum(['text', 'password', 'url', 'email']),
7+
field_type: z.enum(['text', 'password', 'url', 'email', 'file']),
88
required: z.boolean(),
99
description: z.string().optional(),
1010
placeholder: z.string().optional(),

MakerMatrix/frontend/src/services/ai.service.ts

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { apiClient } from './api'
1+
import { apiClient, type ApiResponse } from './api'
22

33
export interface AICommandResponse {
44
action?: 'search-parts' | 'navigate' | 'query' | 'update'
@@ -26,11 +26,14 @@ class AIService {
2626
context?: Record<string, unknown>
2727
): Promise<AICommandResponse> {
2828
try {
29-
const response = await apiClient.post<AICommandResponse>('/api/ai/process-command', {
30-
command,
31-
context,
32-
})
33-
return response.data
29+
const response = await apiClient.post<ApiResponse<AICommandResponse>>(
30+
'/api/ai/process-command',
31+
{
32+
command,
33+
context,
34+
}
35+
)
36+
return response.data as AICommandResponse
3437
} catch (error) {
3538
console.error('AI service error:', error)
3639
// Fallback to local processing if API fails
@@ -91,17 +94,20 @@ class AIService {
9194
}
9295

9396
async generateLabel(partId: number): Promise<{ label_data: string }> {
94-
const response = await apiClient.post<{ label_data: string }>(
97+
const response = await apiClient.post<ApiResponse<{ label_data: string }>>(
9598
`/api/ai/generate-label/${partId}`
9699
)
97-
return response
100+
return response.data as { label_data: string }
98101
}
99102

100103
async suggestCategories(partName: string): Promise<string[]> {
101-
const response = await apiClient.post<{ categories: string[] }>('/api/ai/suggest-categories', {
102-
part_name: partName,
103-
})
104-
return response.categories
104+
const response = await apiClient.post<ApiResponse<{ categories: string[] }>>(
105+
'/api/ai/suggest-categories',
106+
{
107+
part_name: partName,
108+
}
109+
)
110+
return response.data?.categories || []
105111
}
106112
}
107113

MakerMatrix/frontend/src/services/apiKey.service.ts

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { apiClient } from './api'
1+
import { apiClient, type ApiResponse } from './api'
22

33
export interface APIKeyCreate {
44
name: string
@@ -42,41 +42,41 @@ class APIKeyService {
4242
* Get all API keys for the current user
4343
*/
4444
async getUserApiKeys(): Promise<APIKey[]> {
45-
const response = await apiClient.get<APIKey[]>('/api/api-keys/')
46-
return response || []
45+
const response = await apiClient.get<ApiResponse<APIKey[]>>('/api/api-keys/')
46+
return response.data || []
4747
}
4848

4949
/**
5050
* Create a new API key
5151
* Returns the key with plaintext api_key - only shown once!
5252
*/
5353
async createApiKey(keyData: APIKeyCreate): Promise<APIKeyWithKey> {
54-
const response = await apiClient.post<APIKeyWithKey>('/api/api-keys/', keyData)
55-
return response
54+
const response = await apiClient.post<ApiResponse<APIKeyWithKey>>('/api/api-keys/', keyData)
55+
return response.data as APIKeyWithKey
5656
}
5757

5858
/**
5959
* Get a specific API key by ID
6060
*/
6161
async getApiKey(keyId: string): Promise<APIKey> {
62-
const response = await apiClient.get<APIKey>(`/api/api-keys/${keyId}`)
63-
return response
62+
const response = await apiClient.get<ApiResponse<APIKey>>(`/api/api-keys/${keyId}`)
63+
return response.data as APIKey
6464
}
6565

6666
/**
6767
* Update an API key
6868
*/
6969
async updateApiKey(keyId: string, updates: Partial<APIKeyCreate>): Promise<APIKey> {
70-
const response = await apiClient.put<APIKey>(`/api/api-keys/${keyId}`, updates)
71-
return response
70+
const response = await apiClient.put<ApiResponse<APIKey>>(`/api/api-keys/${keyId}`, updates)
71+
return response.data as APIKey
7272
}
7373

7474
/**
7575
* Revoke (deactivate) an API key
7676
*/
7777
async revokeApiKey(keyId: string): Promise<APIKey> {
78-
const response = await apiClient.post<APIKey>(`/api/api-keys/${keyId}/revoke`)
79-
return response
78+
const response = await apiClient.post<ApiResponse<APIKey>>(`/api/api-keys/${keyId}/revoke`)
79+
return response.data as APIKey
8080
}
8181

8282
/**
@@ -90,19 +90,19 @@ class APIKeyService {
9090
* Get all API keys in the system (admin only)
9191
*/
9292
async getAllApiKeys(): Promise<APIKey[]> {
93-
const response = await apiClient.get<APIKey[]>('/api/api-keys/admin/all')
94-
return response || []
93+
const response = await apiClient.get<ApiResponse<APIKey[]>>('/api/api-keys/admin/all')
94+
return response.data || []
9595
}
9696

9797
/**
9898
* Get all available permissions in the system
9999
* Dynamically fetched from backend based on role definitions
100100
*/
101101
async getAvailablePermissions(): Promise<AvailablePermission[]> {
102-
const response = await apiClient.get<AvailablePermission[]>(
102+
const response = await apiClient.get<ApiResponse<AvailablePermission[]>>(
103103
'/api/api-keys/permissions/available'
104104
)
105-
return response || []
105+
return response.data || []
106106
}
107107
}
108108

0 commit comments

Comments
 (0)