diff --git a/package.json b/package.json index df45f52..f98aea4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "continuum-frontend", "private": true, - "version": "0.0.7", + "version": "0.0.8", "scripts": { "build": "turbo build", "rebuild": "turbo build --no-cache --force", diff --git a/workflow-editor-extension/src/browser/components/node-dialog/CredentialRenderer.tsx b/workflow-editor-extension/src/browser/components/node-dialog/CredentialRenderer.tsx new file mode 100644 index 0000000..09529f7 --- /dev/null +++ b/workflow-editor-extension/src/browser/components/node-dialog/CredentialRenderer.tsx @@ -0,0 +1,292 @@ +import React from 'react'; +import { ControlProps } from '@jsonforms/core'; +import { withJsonFormsControlProps } from '@jsonforms/react'; +import { + Box, + FormHelperText, + Typography, + TextField, + Button, + Autocomplete, + CircularProgress, +} from '@mui/material'; +import { isControl, rankWith } from '@jsonforms/core'; +import AddIcon from '@mui/icons-material/Add'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import { credentialsService, CredentialResponse } from '../../service/CredentialsService'; + +// Mock credentials for local testing - set to true to use mock data +const USE_MOCK_DATA = false; + +const MOCK_CREDENTIALS: Record = { + BASIC: [ + { + userId: 'anonymous', + name: 'admin-basic-auth', + type: 'BASIC', + typeVersion: '1.0.0', + description: 'Admin credentials for internal APIs', + createdAt: '2026-04-24T10:00:00Z', + updatedAt: '2026-04-24T10:00:00Z', + }, + { + userId: 'anonymous', + name: 'service-account', + type: 'BASIC', + typeVersion: '1.0.0', + description: 'Service account for batch jobs', + createdAt: '2026-04-24T11:00:00Z', + updatedAt: '2026-04-24T11:00:00Z', + }, + ], + TOKEN: [ + { + userId: 'anonymous', + name: 'github-token', + type: 'TOKEN', + typeVersion: '1.0.0', + description: 'GitHub API access token', + createdAt: '2026-04-24T12:00:00Z', + updatedAt: '2026-04-24T12:00:00Z', + }, + { + userId: 'anonymous', + name: 'openai-api-key', + type: 'TOKEN', + typeVersion: '1.0.0', + description: 'OpenAI API key for LLM calls', + createdAt: '2026-04-24T13:00:00Z', + updatedAt: '2026-04-24T13:00:00Z', + }, + ], + GENERIC: [ + { + userId: 'anonymous', + name: 'basic-api-creds', + type: 'GENERIC', + typeVersion: '1.0.0', + description: 'Generic API credentials', + createdAt: '2026-04-24T19:14:21Z', + updatedAt: '2026-04-24T19:14:21Z', + }, + ], +}; + +const getMockCredentials = (type?: string): CredentialResponse[] => { + if (type) { + return MOCK_CREDENTIALS[type] || []; + } + // Return all credentials + return Object.values(MOCK_CREDENTIALS).flat(); +}; + +interface CredentialRendererProps extends ControlProps { + options?: { + format?: string; + credentialType?: string; + }; +} + +const CredentialRenderer: React.FC = (props) => { + const { + data, + handleChange, + label, + errors, + visible, + path, + uischema, + } = props; + + // Options are in uischema.options, not directly in props + const options = (uischema as any)?.options || {}; + const credentialType = options.credentialType || ''; + const format = options.format || ''; + + console.log('[CredentialRenderer] Render called with props:', { + path, + label, + format, + credentialType, + visible, + data, + options, + uischema, + }); + + const [credentials, setCredentials] = React.useState([]); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(null); + + // Fetch credentials based on type + const fetchCredentials = React.useCallback(async () => { + setLoading(true); + setError(null); + try { + let fetchedCredentials: CredentialResponse[]; + + if (USE_MOCK_DATA) { + // Use mock data for local testing + await new Promise(resolve => setTimeout(resolve, 300)); // Simulate network delay + fetchedCredentials = getMockCredentials(credentialType); + } else if (credentialType) { + // Fetch credentials filtered by type + fetchedCredentials = await credentialsService.listByType(credentialType); + } else { + // Fetch all credentials + fetchedCredentials = await credentialsService.list(); + } + + setCredentials(fetchedCredentials); + } catch (err) { + console.error('Failed to fetch credentials:', err); + setError(err instanceof Error ? err.message : 'Failed to load credentials'); + setCredentials([]); + } finally { + setLoading(false); + } + }, [credentialType]); + + // Fetch credentials only when visible and credentialType changes + React.useEffect(() => { + if (visible && format === 'credential') { + fetchCredentials(); + } + }, [fetchCredentials, visible, format]); + + const handleAutocompleteChange = ( + _event: React.SyntheticEvent, + value: CredentialResponse | null + ) => { + handleChange(path, value?.name || ''); + }; + + const handleAddNew = () => { + // Open credentials manager UI in a new browser tab + window.open('/credentials-manager/ui', '_blank'); + }; + + if (format !== 'credential') { + console.log('[CredentialRenderer] Returning null - format is not "credential":', format); + return null; + } + + if (!visible) { + console.log('[CredentialRenderer] Returning null - not visible'); + return null; + } + + console.log('[CredentialRenderer] Rendering component with credentials:', credentials); + + const hasError = Boolean(errors && errors.length > 0); + const errorMessage = hasError ? errors : undefined; + + return ( + + {label && ( + + {label} + + )} + + option.name} + value={credentials.find((c) => c.name === data) || null} + onChange={handleAutocompleteChange} + loading={loading} + disabled={loading} + isOptionEqualToValue={(option, value) => option.name === value.name} + renderOption={(props, option) => ( +
  • + + {option.name} + {option.description && ( + + {option.description} + + )} + +
  • + )} + renderInput={(params) => ( + + {loading ? : null} + {params.InputProps.endAdornment} + + ), + }} + /> + )} + slotProps={{ + popper: { + sx: { zIndex: 9999 } + } + }} + /> + + +
    + {error && ( + + {error} + + )} + {errorMessage && ( + + {errorMessage} + + )} +
    + ); +}; + +const CredentialControl = withJsonFormsControlProps(CredentialRenderer); + +// Tester to determine when to use this renderer +export const credentialTester = rankWith( + 10, + (uischema: any) => { + const isMatch = isControl(uischema) && uischema.options?.format === 'credential'; + console.log('[credentialTester] Testing uischema:', { + type: uischema?.type, + scope: uischema?.scope, + options: uischema?.options, + isControl: isControl(uischema), + hasCredentialFormat: uischema?.options?.format === 'credential', + isMatch, + }); + return isMatch; + } +); + +export { CredentialControl }; +export default CredentialControl; + + diff --git a/workflow-editor-extension/src/browser/components/node-dialog/NodeDialog.tsx b/workflow-editor-extension/src/browser/components/node-dialog/NodeDialog.tsx index 6139e64..fc866c7 100644 --- a/workflow-editor-extension/src/browser/components/node-dialog/NodeDialog.tsx +++ b/workflow-editor-extension/src/browser/components/node-dialog/NodeDialog.tsx @@ -24,6 +24,7 @@ import { GroupLayout } from '@jsonforms/core'; import CodeEditorControl, { codeEditorTester } from './CodeEditorRenderer'; +import CredentialControl, { credentialTester } from './CredentialRenderer'; /** * Custom Group Layout Renderer @@ -236,8 +237,11 @@ const ResizeHandle = styled('div')(({ theme }) => ({ const MIN_DIALOG_WIDTH = 400; const MIN_DIALOG_HEIGHT = 300; +console.log('[NodeDialog] Registering custom renderers including CredentialControl'); + const customRenderers = [ { tester: codeEditorTester, renderer: CodeEditorControl }, + { tester: credentialTester, renderer: CredentialControl }, { tester: categorizationTester, renderer: MaterialCategorizationLayout }, { tester: groupTester, renderer: MaterialGroupLayout }, ...materialRenderers, diff --git a/workflow-editor-extension/src/browser/service/ApiConfig.ts b/workflow-editor-extension/src/browser/service/ApiConfig.ts index 93f7e49..dde8bfe 100644 --- a/workflow-editor-extension/src/browser/service/ApiConfig.ts +++ b/workflow-editor-extension/src/browser/service/ApiConfig.ts @@ -9,4 +9,5 @@ const isBehindGateway = /^\/workbench\/[^/]+\/open(\/|$)/.test(window.location.pathname); -export const API_SERVER_BASE = isBehindGateway ? '/api-server' : ''; +export const API_SERVER_BASE = isBehindGateway ? '/api-server' : 'http://localhost:8080/api-server'; +export const CREDENTIALS_MANAGER_BASE = isBehindGateway ? '/credentials-manager': 'http://localhost:8080/credentials-manager'; \ No newline at end of file diff --git a/workflow-editor-extension/src/browser/service/CredentialsService.ts b/workflow-editor-extension/src/browser/service/CredentialsService.ts new file mode 100644 index 0000000..80a5866 --- /dev/null +++ b/workflow-editor-extension/src/browser/service/CredentialsService.ts @@ -0,0 +1,103 @@ +import { CREDENTIALS_MANAGER_BASE } from './ApiConfig'; + +const API_BASE = `${CREDENTIALS_MANAGER_BASE}/api/v1/credentials`; + +export interface CredentialResponse { + userId: string; + name: string; + type: string; + typeVersion: string; + description: string | null; + createdAt: string; + updatedAt: string; +} + +export class CredentialsServiceError extends Error { + constructor(public status: number, message: string) { + super(message); + this.name = 'CredentialsServiceError'; + } +} + +async function handleResponse(response: Response): Promise { + if (!response.ok) { + const errorText = await response.text(); + let message = errorText || `HTTP ${response.status}`; + try { + const errorJson = JSON.parse(errorText); + if (errorJson.error) message = errorJson.error; + } catch { + // use raw text + } + throw new CredentialsServiceError(response.status, message); + } + + if (response.status === 204) { + return undefined as T; + } + + return response.json(); +} + +export class CredentialsService { + /** + * List all credentials for the current user + */ + async list(): Promise { + const response = await fetch(API_BASE, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + return handleResponse(response); + } + + /** + * List credentials filtered by type + * @param type The credential type to filter by (e.g., 'BASIC', 'TOKEN') + */ + async listByType(type: string): Promise { + const response = await fetch(`${API_BASE}/type/${encodeURIComponent(type)}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + return handleResponse(response); + } + + /** + * List credentials filtered by type and version + * @param type The credential type to filter by (e.g., 'BASIC', 'TOKEN') + * @param version The credential type version + */ + async listByTypeAndVersion(type: string, version: string): Promise { + const response = await fetch(`${API_BASE}/type/${encodeURIComponent(type)}/${encodeURIComponent(version)}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + return handleResponse(response); + } + + /** + * Get a credential by name + * @param name The credential name + */ + async getByName(name: string): Promise { + const response = await fetch(`${API_BASE}/${encodeURIComponent(name)}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + return handleResponse(response); + } +} + +// Singleton instance +export const credentialsService = new CredentialsService(); + +