From 23dfc171b882b6f600e71f0fe21ba766b0610477 Mon Sep 17 00:00:00 2001 From: Ryan Copley Date: Sun, 31 Aug 2025 23:15:50 -0400 Subject: [PATCH] sidebar fix --- faas/web/src/App.tsx | 43 +- faas/web/src/components/ExecutionSidebar.tsx | 457 ++++++++++++++++ faas/web/src/components/FunctionSidebar.tsx | 502 ++++++++++++++++++ kms/web/src/components/ApplicationSidebar.tsx | 241 +++++++++ kms/web/src/components/Applications.tsx | 104 +--- kms/web/src/components/TokenSidebar.tsx | 293 ++++++++++ kms/web/src/components/Tokens.tsx | 77 +-- user/web/.gitignore | 3 + user/web/src/components/UserManagement.tsx | 32 +- user/web/src/components/UserSidebar.tsx | 255 +++++++++ 10 files changed, 1836 insertions(+), 171 deletions(-) create mode 100644 faas/web/src/components/ExecutionSidebar.tsx create mode 100644 faas/web/src/components/FunctionSidebar.tsx create mode 100644 kms/web/src/components/ApplicationSidebar.tsx create mode 100644 kms/web/src/components/TokenSidebar.tsx create mode 100644 user/web/.gitignore create mode 100644 user/web/src/components/UserSidebar.tsx diff --git a/faas/web/src/App.tsx b/faas/web/src/App.tsx index 735f952..8a23e53 100644 --- a/faas/web/src/App.tsx +++ b/faas/web/src/App.tsx @@ -7,8 +7,8 @@ import { IconStarFilled } from '@tabler/icons-react'; import { FunctionList } from './components/FunctionList'; -import { FunctionForm } from './components/FunctionForm'; -import { ExecutionModal } from './components/ExecutionModal'; +import { FunctionSidebar } from './components/FunctionSidebar'; +import { ExecutionSidebar } from './components/ExecutionSidebar'; import ExecutionList from './components/ExecutionList'; import { FunctionDefinition } from './types'; @@ -24,8 +24,8 @@ const App: React.FC = () => { const [currentRoute, setCurrentRoute] = useState(getCurrentRoute()); const [isFavorited, setIsFavorited] = useState(false); const [selectedColor, setSelectedColor] = useState(''); - const [functionFormOpened, setFunctionFormOpened] = useState(false); - const [executionModalOpened, setExecutionModalOpened] = useState(false); + const [functionSidebarOpened, setFunctionSidebarOpened] = useState(false); + const [executionSidebarOpened, setExecutionSidebarOpened] = useState(false); const [editingFunction, setEditingFunction] = useState(null); const [executingFunction, setExecutingFunction] = useState(null); const [refreshKey, setRefreshKey] = useState(0); @@ -57,30 +57,30 @@ const App: React.FC = () => { const handleCreateFunction = () => { setEditingFunction(null); - setFunctionFormOpened(true); + setFunctionSidebarOpened(true); }; const handleEditFunction = (func: FunctionDefinition) => { setEditingFunction(func); - setFunctionFormOpened(true); + setFunctionSidebarOpened(true); }; const handleExecuteFunction = (func: FunctionDefinition) => { setExecutingFunction(func); - setExecutionModalOpened(true); + setExecutionSidebarOpened(true); }; const handleFormSuccess = () => { setRefreshKey(prev => prev + 1); }; - const handleFormClose = () => { - setFunctionFormOpened(false); + const handleSidebarClose = () => { + setFunctionSidebarOpened(false); setEditingFunction(null); }; const handleExecutionClose = () => { - setExecutionModalOpened(false); + setExecutionSidebarOpened(false); setExecutingFunction(null); }; @@ -124,8 +124,15 @@ const App: React.FC = () => { }; return ( - - + <> +
@@ -186,19 +193,19 @@ const App: React.FC = () => { - - - + ); }; diff --git a/faas/web/src/components/ExecutionSidebar.tsx b/faas/web/src/components/ExecutionSidebar.tsx new file mode 100644 index 0000000..75061e3 --- /dev/null +++ b/faas/web/src/components/ExecutionSidebar.tsx @@ -0,0 +1,457 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { + Paper, + Button, + Group, + Stack, + Text, + Textarea, + Switch, + Alert, + Badge, + Divider, + JsonInput, + Loader, + ActionIcon, + Tooltip, + Title, + ScrollArea, + Box, +} from '@mantine/core'; +import { IconPlayerPlay, IconPlayerStop, IconRefresh, IconCopy, IconX } from '@tabler/icons-react'; +import { notifications } from '@mantine/notifications'; +import { functionApi, executionApi } from '../services/apiService'; +import { FunctionDefinition, ExecuteFunctionResponse, FunctionExecution } from '../types'; + +interface ExecutionSidebarProps { + opened: boolean; + onClose: () => void; + function: FunctionDefinition | null; +} + +export const ExecutionSidebar: React.FC = ({ + opened, + onClose, + function: func, +}) => { + const [input, setInput] = useState('{}'); + const [async, setAsync] = useState(false); + const [executing, setExecuting] = useState(false); + const [result, setResult] = useState(null); + const [execution, setExecution] = useState(null); + const [logs, setLogs] = useState([]); + const [loadingLogs, setLoadingLogs] = useState(false); + const [autoRefreshLogs, setAutoRefreshLogs] = useState(false); + const pollIntervalRef = useRef(null); + const logsPollIntervalRef = useRef(null); + + const stopLogsAutoRefresh = () => { + if (logsPollIntervalRef.current) { + clearInterval(logsPollIntervalRef.current); + logsPollIntervalRef.current = null; + } + setAutoRefreshLogs(false); + }; + + // Cleanup intervals on unmount or when sidebar closes + useEffect(() => { + if (!opened) { + // Stop auto-refresh when sidebar closes + stopLogsAutoRefresh(); + if (pollIntervalRef.current) { + clearTimeout(pollIntervalRef.current); + } + } + }, [opened]); + + // Cleanup intervals on unmount + useEffect(() => { + return () => { + if (pollIntervalRef.current) { + clearTimeout(pollIntervalRef.current); + } + if (logsPollIntervalRef.current) { + clearInterval(logsPollIntervalRef.current); + } + }; + }, []); + + if (!func) return null; + + const handleExecute = async () => { + try { + setExecuting(true); + setResult(null); + setExecution(null); + setLogs([]); + + let parsedInput; + try { + parsedInput = input.trim() ? JSON.parse(input) : undefined; + } catch (e) { + notifications.show({ + title: 'Error', + message: 'Invalid JSON input', + color: 'red', + }); + return; + } + + const response = await functionApi.execute(func.id, { + input: parsedInput, + async, + }); + + setResult(response.data); + + if (async) { + // Poll for execution status and start auto-refreshing logs + pollExecution(response.data.execution_id); + } else { + // For synchronous executions, load logs immediately + if (response.data.execution_id) { + loadLogs(response.data.execution_id); + } + } + + notifications.show({ + title: 'Success', + message: `Function ${async ? 'invoked' : 'executed'} successfully`, + color: 'green', + }); + } catch (error) { + console.error('Execution error:', error); + notifications.show({ + title: 'Error', + message: 'Failed to execute function', + color: 'red', + }); + } finally { + setExecuting(false); + } + }; + + const pollExecution = async (executionId: string) => { + // Start auto-refreshing logs immediately for async executions + startLogsAutoRefresh(executionId); + + const poll = async () => { + try { + const response = await executionApi.getById(executionId); + setExecution(response.data); + + if (response.data.status === 'running' || response.data.status === 'pending') { + pollIntervalRef.current = setTimeout(poll, 2000); // Poll every 2 seconds + } else { + // Execution completed, stop auto-refresh and load final logs + stopLogsAutoRefresh(); + loadLogs(executionId); + } + } catch (error) { + console.error('Error polling execution:', error); + stopLogsAutoRefresh(); + } + }; + + poll(); + }; + + const loadLogs = async (executionId: string) => { + try { + console.debug(`[ExecutionSidebar] Loading logs for execution ${executionId}`); + setLoadingLogs(true); + const response = await executionApi.getLogs(executionId); + console.debug(`[ExecutionSidebar] Loaded logs for execution ${executionId}:`, { + logCount: response.data.logs?.length || 0, + logs: response.data.logs + }); + setLogs(response.data.logs || []); + } catch (error) { + console.error(`[ExecutionSidebar] Error loading logs for execution ${executionId}:`, error); + } finally { + setLoadingLogs(false); + } + }; + + const startLogsAutoRefresh = (executionId: string) => { + console.debug(`[ExecutionSidebar] Starting auto-refresh for execution ${executionId}`); + + // Clear any existing interval + if (logsPollIntervalRef.current) { + clearInterval(logsPollIntervalRef.current); + } + + setAutoRefreshLogs(true); + + // Load logs immediately + loadLogs(executionId); + + // Set up auto-refresh every 2 seconds + logsPollIntervalRef.current = setInterval(async () => { + try { + console.debug(`[ExecutionSidebar] Auto-refreshing logs for execution ${executionId}`); + const response = await executionApi.getLogs(executionId); + console.debug(`[ExecutionSidebar] Auto-refresh got logs for execution ${executionId}:`, { + logCount: response.data.logs?.length || 0, + logs: response.data.logs + }); + setLogs(response.data.logs || []); + } catch (error) { + console.error(`[ExecutionSidebar] Error auto-refreshing logs for execution ${executionId}:`, error); + } + }, 2000); + }; + + const handleCancel = async () => { + if (result && async) { + try { + await executionApi.cancel(result.execution_id); + notifications.show({ + title: 'Success', + message: 'Execution canceled', + color: 'orange', + }); + // Refresh execution status + if (result.execution_id) { + const response = await executionApi.getById(result.execution_id); + setExecution(response.data); + } + } catch (error) { + notifications.show({ + title: 'Error', + message: 'Failed to cancel execution', + color: 'red', + }); + } + } + }; + + const getStatusColor = (status: string) => { + switch (status) { + case 'completed': return 'green'; + case 'failed': return 'red'; + case 'running': return 'blue'; + case 'pending': return 'yellow'; + case 'canceled': return 'orange'; + case 'timeout': return 'red'; + default: return 'gray'; + } + }; + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + notifications.show({ + title: 'Copied', + message: 'Copied to clipboard', + color: 'green', + }); + }; + + return ( + + {/* Header */} + + + Execute Function: {func.name} + + + + + + + {/* Content */} + + + + {/* Function Info */} + + +
+ {func.name} + {func.runtime} • {func.memory}MB • {func.timeout} +
+ + {func.runtime} + +
+
+ + {/* Input */} + + + {/* Execution Options */} + + setAsync(event.currentTarget.checked)} + /> + + + {result && async && execution?.status === 'running' && ( + + )} + + + + {/* Results */} + {result && ( + <> + + + + + Execution #{result.execution_id.slice(0, 8)}... + + + {execution?.status || result.status} + + {result.duration && ( + + {result.duration}ms + + )} + {result.memory_used && ( + + {result.memory_used}MB + + )} + + + + {/* Output */} + {(result.output || execution?.output) && ( +
+ + Output: + + copyToClipboard(JSON.stringify(result.output || execution?.output, null, 2))} + > + + + + + + + {JSON.stringify(result.output || execution?.output, null, 2)} + + +
+ )} + + {/* Error */} + {(result.error || execution?.error) && ( + + {result.error || execution?.error} + + )} + + {/* Logs */} +
+ + + Logs: + {autoRefreshLogs && ( + + Auto-refreshing + + )} + + + {result.execution_id && ( + + )} + + + + + {loadingLogs ? ( + + + + ) : (logs.length > 0 || (execution?.logs && execution.logs.length > 0)) ? ( + + {(execution?.logs || logs).join('\n')} + + ) : ( + No logs available + )} + +
+
+ + )} +
+
+
+
+ ); +}; \ No newline at end of file diff --git a/faas/web/src/components/FunctionSidebar.tsx b/faas/web/src/components/FunctionSidebar.tsx new file mode 100644 index 0000000..7c74474 --- /dev/null +++ b/faas/web/src/components/FunctionSidebar.tsx @@ -0,0 +1,502 @@ +import React, { useState, useEffect } from 'react'; +import { + Paper, + TextInput, + Select, + NumberInput, + Button, + Group, + Stack, + Text, + Divider, + JsonInput, + Box, + Title, + ActionIcon, + ScrollArea, +} from '@mantine/core'; +import { IconX } from '@tabler/icons-react'; +import { useForm } from '@mantine/form'; +import { notifications } from '@mantine/notifications'; +import Editor from '@monaco-editor/react'; +import { functionApi, runtimeApi } from '../services/apiService'; +import { FunctionDefinition, CreateFunctionRequest, UpdateFunctionRequest, RuntimeType } from '../types'; + +interface FunctionSidebarProps { + opened: boolean; + onClose: () => void; + onSuccess: () => void; + editFunction?: FunctionDefinition; +} + +export const FunctionSidebar: React.FC = ({ + opened, + onClose, + onSuccess, + editFunction, +}) => { + const isEditing = !!editFunction; + const [runtimeOptions, setRuntimeOptions] = useState>([]); + + // Default images for each runtime + const DEFAULT_IMAGES: Record = { + 'nodejs18': 'node:18-alpine', + 'python3.9': 'python:3.9-alpine', + 'go1.20': 'golang:1.20-alpine', + }; + + // Map runtime to Monaco editor language + const getEditorLanguage = (runtime: string): string => { + const languageMap: Record = { + 'nodejs18': 'javascript', + 'python3.9': 'python', + 'go1.20': 'go', + }; + return languageMap[runtime] || 'javascript'; + }; + + // Get default code template based on runtime + const getDefaultCode = (runtime: string): string => { + const templates: Record = { + 'nodejs18': `exports.handler = async (event, context) => { + console.log('Event:', JSON.stringify(event, null, 2)); + + return { + statusCode: 200, + body: JSON.stringify({ + message: 'Hello from Node.js!', + timestamp: new Date().toISOString() + }) + }; +};`, + 'python3.9': `import json +from datetime import datetime + +def handler(event, context): + print('Event:', json.dumps(event, indent=2)) + + return { + 'statusCode': 200, + 'body': json.dumps({ + 'message': 'Hello from Python!', + 'timestamp': datetime.now().isoformat() + }) + }`, + 'go1.20': `package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "time" +) + +type Event map[string]interface{} +type Response struct { + StatusCode int \`json:"statusCode"\` + Body string \`json:"body"\` +} + +func Handler(ctx context.Context, event Event) (Response, error) { + eventJSON, _ := json.MarshalIndent(event, "", " ") + log.Printf("Event: %s", eventJSON) + + body := map[string]interface{}{ + "message": "Hello from Go!", + "timestamp": time.Now().Format(time.RFC3339), + } + + bodyJSON, _ := json.Marshal(body) + + return Response{ + StatusCode: 200, + Body: string(bodyJSON), + }, nil +}` + }; + return templates[runtime] || templates['nodejs18']; + }; + + useEffect(() => { + // Fetch available runtimes from backend + const fetchRuntimes = async () => { + try { + const response = await runtimeApi.getRuntimes(); + setRuntimeOptions(response.data.runtimes || []); + } catch (error) { + console.error('Failed to fetch runtimes:', error); + // Fallback to default options + setRuntimeOptions([ + { value: 'nodejs18', label: 'Node.js 18.x' }, + { value: 'python3.9', label: 'Python 3.9' }, + { value: 'go1.20', label: 'Go 1.20' }, + ]); + } + }; + + if (opened) { + fetchRuntimes(); + } + }, [opened]); + + // Update form values when editFunction changes + useEffect(() => { + if (editFunction) { + form.setValues({ + name: editFunction.name || '', + app_id: editFunction.app_id || 'default', + runtime: editFunction.runtime || 'nodejs18' as RuntimeType, + image: editFunction.image || DEFAULT_IMAGES['nodejs18'] || '', + handler: editFunction.handler || 'index.handler', + code: editFunction.code || '', + environment: editFunction.environment ? JSON.stringify(editFunction.environment, null, 2) : '{}', + timeout: editFunction.timeout || '30s', + memory: editFunction.memory || 128, + owner: { + type: editFunction.owner?.type || 'team' as const, + name: editFunction.owner?.name || 'FaaS Team', + owner: editFunction.owner?.owner || 'admin@example.com', + }, + }); + } else { + // Reset to default values when not editing + form.setValues({ + name: '', + app_id: 'default', + runtime: 'nodejs18' as RuntimeType, + image: DEFAULT_IMAGES['nodejs18'] || '', + handler: 'index.handler', + code: getDefaultCode('nodejs18'), + environment: '{}', + timeout: '30s', + memory: 128, + owner: { + type: 'team' as const, + name: 'FaaS Team', + owner: 'admin@example.com', + }, + }); + } + }, [editFunction, opened]); + + const form = useForm({ + initialValues: { + name: '', + app_id: 'default', + runtime: 'nodejs18' as RuntimeType, + image: DEFAULT_IMAGES['nodejs18'] || '', + handler: 'index.handler', + code: getDefaultCode('nodejs18'), + environment: '{}', + timeout: '30s', + memory: 128, + owner: { + type: 'team' as const, + name: 'FaaS Team', + owner: 'admin@example.com', + }, + }, + validate: { + name: (value) => value.length < 1 ? 'Name is required' : null, + app_id: (value) => value.length < 1 ? 'App ID is required' : null, + runtime: (value) => !value ? 'Runtime is required' : null, + image: (value) => value.length < 1 ? 'Image is required' : null, + handler: (value) => value.length < 1 ? 'Handler is required' : null, + timeout: (value) => !value.match(/^\d+[smh]$/) ? 'Timeout must be in format like 30s, 5m, 1h' : null, + memory: (value) => value < 64 || value > 3008 ? 'Memory must be between 64 and 3008 MB' : null, + }, + }); + + const handleRuntimeChange = (runtime: string | null) => { + if (runtime && DEFAULT_IMAGES[runtime]) { + form.setFieldValue('image', DEFAULT_IMAGES[runtime]); + } + form.setFieldValue('runtime', runtime as RuntimeType); + + // If creating a new function and no code is set, provide default template + if (!isEditing && runtime && (!form.values.code || form.values.code.trim() === '')) { + form.setFieldValue('code', getDefaultCode(runtime)); + } + }; + + const handleSubmit = async (values: typeof form.values) => { + console.log('handleSubmit called with values:', values); + console.log('Form validation errors:', form.errors); + console.log('Is form valid?', form.isValid()); + + // Check each field individually + const fieldNames = ['name', 'app_id', 'runtime', 'image', 'handler', 'timeout', 'memory']; + fieldNames.forEach(field => { + const error = form.validateField(field); + console.log(`Field ${field} error:`, error); + }); + + if (!form.isValid()) { + console.log('Form is not valid, validation errors:', form.errors); + return; + } + try { + // Parse environment variables JSON + let parsedEnvironment; + try { + parsedEnvironment = values.environment ? JSON.parse(values.environment) : undefined; + } catch (error) { + console.error('Error parsing environment variables:', error); + notifications.show({ + title: 'Error', + message: 'Invalid JSON in environment variables', + color: 'red', + }); + return; + } + if (isEditing && editFunction) { + const updateData: UpdateFunctionRequest = { + name: values.name, + runtime: values.runtime, + image: values.image, + handler: values.handler, + code: values.code || undefined, + environment: parsedEnvironment, + timeout: values.timeout, + memory: values.memory, + owner: values.owner, + }; + await functionApi.update(editFunction.id, updateData); + notifications.show({ + title: 'Success', + message: 'Function updated successfully', + color: 'green', + }); + } else { + const createData: CreateFunctionRequest = { + name: values.name, + app_id: values.app_id, + runtime: values.runtime, + image: values.image, + handler: values.handler, + code: values.code || undefined, + environment: parsedEnvironment, + timeout: values.timeout, + memory: values.memory, + owner: values.owner, + }; + await functionApi.create(createData); + notifications.show({ + title: 'Success', + message: 'Function created successfully', + color: 'green', + }); + } + onSuccess(); + onClose(); + form.reset(); + } catch (error) { + console.error('Error saving function:', error); + notifications.show({ + title: 'Error', + message: `Failed to ${isEditing ? 'update' : 'create'} function`, + color: 'red', + }); + } + }; + + return ( + + {/* Header */} + + + {isEditing ? 'Edit Function' : 'Create Function'} + + + + + + + {/* Content */} + + +
{ + console.log('Form submit event triggered'); + console.log('Form values:', form.values); + console.log('Form errors:', form.errors); + console.log('Is form valid?', form.isValid()); + const result = form.onSubmit(handleSubmit)(e); + console.log('Form onSubmit result:', result); + return result; + }}> + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ ); +}; \ No newline at end of file diff --git a/kms/web/src/components/ApplicationSidebar.tsx b/kms/web/src/components/ApplicationSidebar.tsx new file mode 100644 index 0000000..80e23d6 --- /dev/null +++ b/kms/web/src/components/ApplicationSidebar.tsx @@ -0,0 +1,241 @@ +import React, { useEffect } from 'react'; +import { + Paper, + TextInput, + MultiSelect, + Button, + Group, + Stack, + Title, + ActionIcon, + ScrollArea, + Box, +} from '@mantine/core'; +import { IconX } from '@tabler/icons-react'; +import { useForm } from '@mantine/form'; +import { notifications } from '@mantine/notifications'; +import { apiService, Application, CreateApplicationRequest } from '../services/apiService'; + +interface ApplicationSidebarProps { + opened: boolean; + onClose: () => void; + onSuccess: () => void; + editingApp?: Application | null; +} + +const ApplicationSidebar: React.FC = ({ + opened, + onClose, + onSuccess, + editingApp, +}) => { + const isEditing = !!editingApp; + + const appTypeOptions = [ + { value: 'static', label: 'Static Token App' }, + { value: 'user', label: 'User Token App' }, + ]; + + const form = useForm({ + initialValues: { + app_id: '', + app_link: '', + type: [], + callback_url: '', + token_prefix: '', + token_renewal_duration: '24h', + max_token_duration: '168h', + owner: { + type: 'individual', + name: 'Admin User', + owner: 'admin@example.com', + }, + }, + validate: { + app_id: (value) => value.length < 1 ? 'App ID is required' : null, + app_link: (value) => value.length < 1 ? 'App Link is required' : null, + callback_url: (value) => value.length < 1 ? 'Callback URL is required' : null, + }, + }); + + const parseDuration = (duration: string): number => { + // Convert duration string like "24h" to seconds + const match = duration.match(/^(\d+)([hmd]?)$/); + if (!match) return 86400; // Default to 24h in seconds + + const value = parseInt(match[1]); + const unit = match[2] || 'h'; + + switch (unit) { + case 'm': return value * 60; // minutes to seconds + case 'h': return value * 3600; // hours to seconds + case 'd': return value * 86400; // days to seconds + default: return value * 3600; // default to hours + } + }; + + // Update form values when editingApp changes + useEffect(() => { + if (editingApp) { + form.setValues({ + app_id: editingApp.app_id || '', + app_link: editingApp.app_link || '', + type: editingApp.type || [], + callback_url: editingApp.callback_url || '', + token_prefix: editingApp.token_prefix || '', + token_renewal_duration: editingApp.token_renewal_duration || '24h', + max_token_duration: editingApp.max_token_duration || '168h', + owner: { + type: editingApp.owner?.type || 'individual', + name: editingApp.owner?.name || 'Admin User', + owner: editingApp.owner?.owner || 'admin@example.com', + }, + }); + } else { + // Reset to default values when not editing + form.reset(); + } + }, [editingApp, opened]); + + const handleSubmit = async (values: typeof form.values) => { + try { + const submitData = { + ...values, + token_renewal_duration_seconds: parseDuration(values.token_renewal_duration), + max_token_duration_seconds: parseDuration(values.max_token_duration), + }; + + if (isEditing && editingApp) { + await apiService.updateApplication(editingApp.app_id, submitData); + notifications.show({ + title: 'Success', + message: 'Application updated successfully', + color: 'green', + }); + } else { + await apiService.createApplication(submitData); + notifications.show({ + title: 'Success', + message: 'Application created successfully', + color: 'green', + }); + } + + onSuccess(); + onClose(); + form.reset(); + } catch (error) { + console.error('Error saving application:', error); + notifications.show({ + title: 'Error', + message: `Failed to ${isEditing ? 'update' : 'create'} application`, + color: 'red', + }); + } + }; + + return ( + + {/* Header */} + + + {isEditing ? 'Edit Application' : 'Create New Application'} + + + + + + + {/* Content */} + + +
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ ); +}; + +export default ApplicationSidebar; \ No newline at end of file diff --git a/kms/web/src/components/Applications.tsx b/kms/web/src/components/Applications.tsx index 2f2ce4c..73d3a31 100644 --- a/kms/web/src/components/Applications.tsx +++ b/kms/web/src/components/Applications.tsx @@ -29,12 +29,13 @@ import { import { useForm } from '@mantine/form'; import { notifications } from '@mantine/notifications'; import { apiService, Application, CreateApplicationRequest } from '../services/apiService'; +import ApplicationSidebar from './ApplicationSidebar'; import dayjs from 'dayjs'; const Applications: React.FC = () => { const [applications, setApplications] = useState([]); const [loading, setLoading] = useState(false); - const [modalOpen, setModalOpen] = useState(false); + const [sidebarOpen, setSidebarOpen] = useState(false); const [editingApp, setEditingApp] = useState(null); const [detailModalOpen, setDetailModalOpen] = useState(false); const [selectedApp, setSelectedApp] = useState(null); @@ -122,7 +123,7 @@ const Applications: React.FC = () => { color: 'green', }); } - setModalOpen(false); + setSidebarOpen(false); setEditingApp(null); form.reset(); loadApplications(); @@ -148,7 +149,7 @@ const Applications: React.FC = () => { max_token_duration: `${app.max_token_duration / 3600}h`, owner: app.owner, }); - setModalOpen(true); + setSidebarOpen(true); }; const handleDelete = async (appId: string) => { @@ -247,7 +248,13 @@ const Applications: React.FC = () => { )); return ( - +
@@ -259,7 +266,7 @@ const Applications: React.FC = () => { onClick={() => { setEditingApp(null); form.reset(); - setModalOpen(true); + setSidebarOpen(true); }} > New Application @@ -288,7 +295,7 @@ const Applications: React.FC = () => { onClick={() => { setEditingApp(null); form.reset(); - setModalOpen(true); + setSidebarOpen(true); }} > Create Application @@ -312,86 +319,17 @@ const Applications: React.FC = () => { </Card> )} - {/* Create/Edit Modal */} - <Modal - opened={modalOpen} + <ApplicationSidebar + opened={sidebarOpen} onClose={() => { - setModalOpen(false); + setSidebarOpen(false); setEditingApp(null); - form.reset(); }} - title={editingApp ? 'Edit Application' : 'Create New Application'} - size="lg" - > - <form onSubmit={form.onSubmit(handleSubmit)}> - <Stack gap="md"> - <TextInput - label="Application ID" - placeholder="my-app-id" - required - {...form.getInputProps('app_id')} - disabled={!!editingApp} - /> - - <TextInput - label="Application Link" - placeholder="https://myapp.example.com" - required - {...form.getInputProps('app_link')} - /> - - <MultiSelect - label="Application Type" - placeholder="Select application types" - data={appTypeOptions} - required - {...form.getInputProps('type')} - /> - - <TextInput - label="Callback URL" - placeholder="https://myapp.example.com/callback" - required - {...form.getInputProps('callback_url')} - /> - - <TextInput - label="Token Prefix (Optional)" - placeholder="myapp_" - {...form.getInputProps('token_prefix')} - /> - - <Group grow> - <TextInput - label="Token Renewal Duration" - placeholder="24h" - {...form.getInputProps('token_renewal_duration')} - /> - <TextInput - label="Max Token Duration" - placeholder="168h" - {...form.getInputProps('max_token_duration')} - /> - </Group> - - <Group justify="flex-end" mt="md"> - <Button - variant="subtle" - onClick={() => { - setModalOpen(false); - setEditingApp(null); - form.reset(); - }} - > - Cancel - </Button> - <Button type="submit"> - {editingApp ? 'Update Application' : 'Create Application'} - </Button> - </Group> - </Stack> - </form> - </Modal> + onSuccess={() => { + loadApplications(); + }} + editingApp={editingApp} + /> {/* Detail Modal */} <Modal diff --git a/kms/web/src/components/TokenSidebar.tsx b/kms/web/src/components/TokenSidebar.tsx new file mode 100644 index 0000000..e3ac77d --- /dev/null +++ b/kms/web/src/components/TokenSidebar.tsx @@ -0,0 +1,293 @@ +import React, { useState, useEffect } from 'react'; +import { + Paper, + TextInput, + Button, + Group, + Stack, + Title, + ActionIcon, + ScrollArea, + Box, + Select, + Alert, + Code, + Divider, + Text, + Modal, +} from '@mantine/core'; +import { IconX, IconCheck, IconCopy } from '@tabler/icons-react'; +import { useForm } from '@mantine/form'; +import { notifications } from '@mantine/notifications'; +import PermissionTree from './PermissionTree'; +import { + apiService, + Application, + CreateTokenRequest, + CreateTokenResponse, +} from '../services/apiService'; + +interface TokenSidebarProps { + opened: boolean; + onClose: () => void; + onSuccess: () => void; + applications: Application[]; +} + +const TokenSidebar: React.FC<TokenSidebarProps> = ({ + opened, + onClose, + onSuccess, + applications, +}) => { + const [tokenModalOpen, setTokenModalOpen] = useState(false); + const [createdToken, setCreatedToken] = useState<CreateTokenResponse | null>(null); + + const form = useForm<CreateTokenRequest & { app_id: string }>({ + initialValues: { + app_id: '', + owner: { + type: 'individual', + name: 'Admin User', + owner: 'admin@example.com', + }, + permissions: [], + }, + validate: { + app_id: (value) => value.length < 1 ? 'Application is required' : null, + permissions: (value) => value.length < 1 ? 'At least one permission is required' : null, + }, + }); + + // Reset form when sidebar opens + useEffect(() => { + if (opened) { + form.reset(); + } + }, [opened]); + + const handleSubmit = async (values: CreateTokenRequest & { app_id: string }) => { + try { + const { app_id, ...tokenData } = values; + const response = await apiService.createToken(app_id, tokenData); + setCreatedToken(response); + setTokenModalOpen(true); + form.reset(); + onSuccess(); + notifications.show({ + title: 'Success', + message: 'Token created successfully', + color: 'green', + }); + } catch (error) { + console.error('Failed to create token:', error); + notifications.show({ + title: 'Error', + message: 'Failed to create token', + color: 'red', + }); + } + }; + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + notifications.show({ + title: 'Copied', + message: 'Copied to clipboard', + color: 'blue', + }); + }; + + const handleTokenModalClose = () => { + setTokenModalOpen(false); + setCreatedToken(null); + onClose(); + }; + + return ( + <> + <Paper + style={{ + position: 'fixed', + top: 60, // Below header + right: opened ? 0 : '-450px', + bottom: 0, + width: '450px', + zIndex: 1000, + borderRadius: 0, + display: 'flex', + flexDirection: 'column', + borderLeft: '1px solid var(--mantine-color-gray-3)', + backgroundColor: 'var(--mantine-color-body)', + transition: 'right 0.3s ease', + }} + > + {/* Header */} + <Group justify="space-between" p="md" style={{ borderBottom: '1px solid var(--mantine-color-gray-3)' }}> + <Title order={4}> + Create New Token + + + + + + + {/* Content */} + + +
+ + ({ - value: app.app_id, - label: `${app.app_id} (${app.type.join(', ')})`, - }))} - {...form.getInputProps('app_id')} - /> - - form.setFieldValue('permissions', permissions)} - /> - - - - - - - - - - -
- + onSuccess={() => { + loadAllTokens(); + }} + applications={applications} + /> {/* Token Created Modal */} { const [roleFilter, setRoleFilter] = useState(null); const [currentPage, setCurrentPage] = useState(1); const [totalUsers, setTotalUsers] = useState(0); - const [userFormOpened, setUserFormOpened] = useState(false); + const [userSidebarOpened, setUserSidebarOpened] = useState(false); const [editingUser, setEditingUser] = useState(null); const pageSize = 10; @@ -76,12 +76,12 @@ const UserManagement: React.FC = () => { const handleCreateUser = () => { setEditingUser(null); - setUserFormOpened(true); + setUserSidebarOpened(true); }; const handleEditUser = (user: User) => { setEditingUser(user); - setUserFormOpened(true); + setUserSidebarOpened(true); }; const handleDeleteUser = (user: User) => { @@ -115,12 +115,12 @@ const UserManagement: React.FC = () => { }); }; - const handleUserFormSuccess = () => { + const handleUserSidebarSuccess = () => { loadUsers(); }; - const handleUserFormClose = () => { - setUserFormOpened(false); + const handleUserSidebarClose = () => { + setUserSidebarOpened(false); setEditingUser(null); }; @@ -149,7 +149,13 @@ const UserManagement: React.FC = () => { return ( <> {/* Main user management interface */} - + + + + + +
+
+ + ); +}; + +export default UserSidebar; \ No newline at end of file