sidebar fix

This commit is contained in:
2025-08-31 23:15:50 -04:00
parent 1430c97ae7
commit 23dfc171b8
10 changed files with 1836 additions and 171 deletions

View File

@ -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<FunctionDefinition | null>(null);
const [executingFunction, setExecutingFunction] = useState<FunctionDefinition | null>(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 (
<Box w="100%" pos="relative">
<Stack gap="lg">
<>
<Stack
gap="lg"
style={{
transition: 'margin-right 0.3s ease',
marginRight: (functionSidebarOpened || executionSidebarOpened) ?
(functionSidebarOpened ? '500px' : '600px') : '0',
}}
>
<div>
<Group justify="space-between" align="flex-start">
<div>
@ -186,19 +193,19 @@ const App: React.FC = () => {
</Tabs>
</Stack>
<FunctionForm
opened={functionFormOpened}
onClose={handleFormClose}
<FunctionSidebar
opened={functionSidebarOpened}
onClose={handleSidebarClose}
onSuccess={handleFormSuccess}
editFunction={editingFunction}
/>
<ExecutionModal
opened={executionModalOpened}
<ExecutionSidebar
opened={executionSidebarOpened}
onClose={handleExecutionClose}
function={executingFunction}
/>
</Box>
</>
);
};

View File

@ -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<ExecutionSidebarProps> = ({
opened,
onClose,
function: func,
}) => {
const [input, setInput] = useState('{}');
const [async, setAsync] = useState(false);
const [executing, setExecuting] = useState(false);
const [result, setResult] = useState<ExecuteFunctionResponse | null>(null);
const [execution, setExecution] = useState<FunctionExecution | null>(null);
const [logs, setLogs] = useState<string[]>([]);
const [loadingLogs, setLoadingLogs] = useState(false);
const [autoRefreshLogs, setAutoRefreshLogs] = useState(false);
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
const logsPollIntervalRef = useRef<NodeJS.Timeout | null>(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 (
<Paper
style={{
position: 'fixed',
top: 60, // Below header
right: opened ? 0 : '-600px',
bottom: 0,
width: '600px',
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}>
Execute Function: {func.name}
</Title>
<ActionIcon
variant="subtle"
color="gray"
onClick={onClose}
>
<IconX size={18} />
</ActionIcon>
</Group>
{/* Content */}
<ScrollArea style={{ flex: 1 }}>
<Box p="md">
<Stack gap="md">
{/* Function Info */}
<Paper withBorder p="sm" bg="gray.0">
<Group justify="space-between">
<div>
<Text size="sm" fw={500}>{func.name}</Text>
<Text size="xs" c="dimmed">{func.runtime} {func.memory}MB {func.timeout}</Text>
</div>
<Badge variant="light">
{func.runtime}
</Badge>
</Group>
</Paper>
{/* Input */}
<JsonInput
label="Function Input (JSON)"
placeholder='{"key": "value"}'
value={input}
onChange={setInput}
minRows={4}
maxRows={8}
validationError="Invalid JSON"
/>
{/* Execution Options */}
<Group justify="space-between">
<Switch
label="Asynchronous execution"
description="Execute in background"
checked={async}
onChange={(event) => setAsync(event.currentTarget.checked)}
/>
<Group>
<Button
leftSection={<IconPlayerPlay size={16} />}
onClick={handleExecute}
loading={executing}
disabled={executing}
>
{async ? 'Invoke' : 'Execute'}
</Button>
{result && async && execution?.status === 'running' && (
<Button
leftSection={<IconPlayerStop size={16} />}
color="orange"
variant="light"
onClick={handleCancel}
>
Cancel
</Button>
)}
</Group>
</Group>
{/* Results */}
{result && (
<>
<Divider label="Execution Result" labelPosition="center" />
<Paper withBorder p="md">
<Group justify="space-between" mb="sm">
<Text fw={500}>Execution #{result.execution_id.slice(0, 8)}...</Text>
<Group gap="xs">
<Badge color={getStatusColor(execution?.status || result.status)}>
{execution?.status || result.status}
</Badge>
{result.duration && (
<Badge variant="light">
{result.duration}ms
</Badge>
)}
{result.memory_used && (
<Badge variant="light">
{result.memory_used}MB
</Badge>
)}
</Group>
</Group>
{/* Output */}
{(result.output || execution?.output) && (
<div>
<Group justify="space-between" mb="xs">
<Text size="sm" fw={500}>Output:</Text>
<Tooltip label="Copy output">
<ActionIcon
variant="light"
size="sm"
onClick={() => copyToClipboard(JSON.stringify(result.output || execution?.output, null, 2))}
>
<IconCopy size={14} />
</ActionIcon>
</Tooltip>
</Group>
<Paper bg="gray.1" p="sm">
<Text size="sm" component="pre" style={{ whiteSpace: 'pre-wrap' }}>
{JSON.stringify(result.output || execution?.output, null, 2)}
</Text>
</Paper>
</div>
)}
{/* Error */}
{(result.error || execution?.error) && (
<Alert color="red" mt="sm">
<Text size="sm">{result.error || execution?.error}</Text>
</Alert>
)}
{/* Logs */}
<div style={{ marginTop: '1rem' }}>
<Group justify="space-between" mb="xs">
<Group gap="xs">
<Text size="sm" fw={500}>Logs:</Text>
{autoRefreshLogs && (
<Badge size="xs" color="blue" variant="light">
Auto-refreshing
</Badge>
)}
</Group>
<Group gap="xs">
{result.execution_id && (
<Button
size="xs"
variant={autoRefreshLogs ? "filled" : "light"}
color={autoRefreshLogs ? "red" : "blue"}
leftSection={<IconRefresh size={12} />}
onClick={() => {
if (autoRefreshLogs) {
stopLogsAutoRefresh();
} else {
startLogsAutoRefresh(result.execution_id);
}
}}
>
{autoRefreshLogs ? 'Stop Auto-refresh' : 'Auto-refresh'}
</Button>
)}
<Button
size="xs"
variant="light"
leftSection={<IconRefresh size={12} />}
onClick={() => result.execution_id && loadLogs(result.execution_id)}
loading={loadingLogs}
disabled={autoRefreshLogs}
>
Manual Refresh
</Button>
</Group>
</Group>
<Paper bg="gray.9" p="sm" mah={200} style={{ overflow: 'auto' }}>
{loadingLogs ? (
<Group justify="center">
<Loader size="sm" />
</Group>
) : (logs.length > 0 || (execution?.logs && execution.logs.length > 0)) ? (
<Text size="xs" c="white" component="pre">
{(execution?.logs || logs).join('\n')}
</Text>
) : (
<Text size="xs" c="gray.5">No logs available</Text>
)}
</Paper>
</div>
</Paper>
</>
)}
</Stack>
</Box>
</ScrollArea>
</Paper>
);
};

View File

@ -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<FunctionSidebarProps> = ({
opened,
onClose,
onSuccess,
editFunction,
}) => {
const isEditing = !!editFunction;
const [runtimeOptions, setRuntimeOptions] = useState<Array<{value: string; label: string}>>([]);
// Default images for each runtime
const DEFAULT_IMAGES: Record<string, string> = {
'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<string, string> = {
'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<string, string> = {
'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 (
<Paper
style={{
position: 'fixed',
top: 60, // Below header
right: opened ? 0 : '-500px',
bottom: 0,
width: '500px',
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}>
{isEditing ? 'Edit Function' : 'Create Function'}
</Title>
<ActionIcon
variant="subtle"
color="gray"
onClick={onClose}
>
<IconX size={18} />
</ActionIcon>
</Group>
{/* Content */}
<ScrollArea style={{ flex: 1 }}>
<Box p="md">
<form onSubmit={(e) => {
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;
}}>
<Stack gap="md">
<Group grow>
<TextInput
label="Function Name"
placeholder="my-function"
required
{...form.getInputProps('name')}
/>
<TextInput
label="App ID"
placeholder="my-app"
required
disabled={isEditing}
{...form.getInputProps('app_id')}
/>
</Group>
<Group grow>
<Select
label="Runtime"
placeholder="Select runtime"
required
data={runtimeOptions}
{...form.getInputProps('runtime')}
onChange={handleRuntimeChange}
/>
<NumberInput
label="Memory (MB)"
placeholder="128"
required
min={64}
max={3008}
{...form.getInputProps('memory')}
/>
</Group>
<Group grow>
<TextInput
label="Timeout"
placeholder="30s"
required
{...form.getInputProps('timeout')}
/>
</Group>
<TextInput
label="Handler"
description="The entry point for your function (e.g., 'index.handler' means handler function in index file)"
placeholder="index.handler"
required
{...form.getInputProps('handler')}
/>
<Box>
<Text size="sm" fw={500} mb={5}>
Function Code
</Text>
<Box
style={{
border: '1px solid #dee2e6',
borderRadius: '4px',
overflow: 'hidden'
}}
>
<Editor
height="300px"
language={getEditorLanguage(form.values.runtime)}
value={form.values.code}
onChange={(value) => form.setFieldValue('code', value || '')}
options={{
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: 12,
lineNumbers: 'on',
roundedSelection: false,
scrollbar: {
vertical: 'visible',
horizontal: 'visible'
},
automaticLayout: true,
wordWrap: 'on',
tabSize: 2,
insertSpaces: true,
folding: true,
lineDecorationsWidth: 0,
lineNumbersMinChars: 3,
renderLineHighlight: 'line',
selectOnLineNumbers: true,
theme: 'vs-light'
}}
loading={<Text ta="center" p="xl">Loading editor...</Text>}
/>
</Box>
{form.errors.code && (
<Text size="xs" c="red" mt={5}>
{form.errors.code}
</Text>
)}
</Box>
<JsonInput
label="Environment Variables"
description="JSON object with key-value pairs that will be available in your function runtime"
placeholder={`{
"NODE_ENV": "production",
"API_URL": "https://api.example.com"
}`}
validationError="Invalid JSON - please check your syntax"
formatOnBlur
autosize
minRows={3}
{...form.getInputProps('environment')}
/>
<Paper withBorder p="md" bg="gray.0">
<Text size="sm" fw={500} mb="xs">Owner Information</Text>
<Group grow>
<Select
label="Owner Type"
data={[
{ value: 'individual', label: 'Individual' },
{ value: 'team', label: 'Team' },
]}
{...form.getInputProps('owner.type')}
/>
<TextInput
label="Owner Name"
placeholder="Team Name"
{...form.getInputProps('owner.name')}
/>
</Group>
<TextInput
label="Owner Email"
placeholder="owner@example.com"
mt="xs"
{...form.getInputProps('owner.owner')}
/>
</Paper>
<Divider />
<Group justify="flex-end">
<Button variant="light" onClick={onClose}>
Cancel
</Button>
<Button type="submit">
{isEditing ? 'Update' : 'Create'} Function
</Button>
</Group>
</Stack>
</form>
</Box>
</ScrollArea>
</Paper>
);
};

View File

@ -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<ApplicationSidebarProps> = ({
opened,
onClose,
onSuccess,
editingApp,
}) => {
const isEditing = !!editingApp;
const appTypeOptions = [
{ value: 'static', label: 'Static Token App' },
{ value: 'user', label: 'User Token App' },
];
const form = useForm<CreateApplicationRequest>({
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 (
<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}>
{isEditing ? 'Edit Application' : 'Create New Application'}
</Title>
<ActionIcon
variant="subtle"
color="gray"
onClick={onClose}
>
<IconX size={18} />
</ActionIcon>
</Group>
{/* Content */}
<ScrollArea style={{ flex: 1 }}>
<Box p="md">
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<TextInput
label="Application ID"
placeholder="my-app-id"
required
{...form.getInputProps('app_id')}
disabled={isEditing}
/>
<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="light"
onClick={onClose}
>
Cancel
</Button>
<Button type="submit">
{isEditing ? 'Update Application' : 'Create Application'}
</Button>
</Group>
</Stack>
</form>
</Box>
</ScrollArea>
</Paper>
);
};
export default ApplicationSidebar;

View File

@ -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<Application[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [editingApp, setEditingApp] = useState<Application | null>(null);
const [detailModalOpen, setDetailModalOpen] = useState(false);
const [selectedApp, setSelectedApp] = useState<Application | null>(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 (
<Stack gap="lg">
<Stack
gap="lg"
style={{
transition: 'margin-right 0.3s ease',
marginRight: sidebarOpen ? '450px' : '0',
}}
>
<Group justify="space-between">
<div>
<Title order={2} mb="xs">
@ -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

View File

@ -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
</Title>
<ActionIcon
variant="subtle"
color="gray"
onClick={onClose}
>
<IconX size={18} />
</ActionIcon>
</Group>
{/* Content */}
<ScrollArea style={{ flex: 1 }}>
<Box p="md">
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Select
label="Application"
placeholder="Select an application"
required
data={applications.map(app => ({
value: app.app_id,
label: `${app.app_id} (${app.type.join(', ')})`,
}))}
{...form.getInputProps('app_id')}
/>
<div>
<Text size="sm" fw={500} mb="xs">
Required Permissions
</Text>
<Text size="xs" c="dimmed" mb="md">
Select the permissions this token should have
</Text>
<PermissionTree
permissions={form.values.permissions}
onChange={(permissions) => form.setFieldValue('permissions', permissions)}
/>
</div>
<TextInput
label="Owner Name"
placeholder="Token owner name"
{...form.getInputProps('owner.name')}
/>
<TextInput
label="Owner Email"
placeholder="owner@example.com"
{...form.getInputProps('owner.owner')}
/>
<Group justify="flex-end" mt="md">
<Button
variant="light"
onClick={onClose}
>
Cancel
</Button>
<Button
type="submit"
disabled={applications.length === 0}
>
Create Token
</Button>
</Group>
</Stack>
</form>
</Box>
</ScrollArea>
</Paper>
{/* Token Created Modal */}
<Modal
opened={tokenModalOpen}
onClose={handleTokenModalClose}
title="Token Created Successfully"
size="lg"
closeOnEscape={false}
closeOnClickOutside={false}
>
<Stack gap="md">
<Alert
icon={<IconCheck size={16} />}
title="Success!"
color="green"
>
Your token has been created successfully. Please copy and store it securely as you won't be able to see it again.
</Alert>
<div>
<Text size="sm" fw={500} mb="xs">
Token:
</Text>
<Group gap="xs">
<Code
block
style={{
flex: 1,
wordBreak: 'break-all',
whiteSpace: 'pre-wrap',
}}
>
{createdToken?.token}
</Code>
<ActionIcon
variant="light"
onClick={() => createdToken?.token && copyToClipboard(createdToken.token)}
title="Copy Token"
>
<IconCopy size={16} />
</ActionIcon>
</Group>
</div>
{createdToken?.prefix && (
<div>
<Text size="sm" fw={500} mb="xs">
Token Prefix:
</Text>
<Code>{createdToken.prefix}</Code>
</div>
)}
<Divider />
<div>
<Text size="sm" fw={500} mb="xs">
Token Details:
</Text>
<Stack gap="xs">
<Group justify="space-between">
<Text size="sm">Token ID:</Text>
<Code>{createdToken?.id}</Code>
</Group>
<Group justify="space-between">
<Text size="sm">Type:</Text>
<Code>{createdToken?.type}</Code>
</Group>
{createdToken?.permissions && (
<div>
<Text size="sm" mb="xs">Permissions:</Text>
<Group gap="xs">
{createdToken.permissions.map((permission) => (
<Code key={permission} size="xs">
{permission}
</Code>
))}
</Group>
</div>
)}
</Stack>
</div>
<Group justify="flex-end" mt="md">
<Button onClick={handleTokenModalClose}>
Done
</Button>
</Group>
</Stack>
</Modal>
</>
);
};
export default TokenSidebar;

View File

@ -38,6 +38,7 @@ import {
CreateTokenRequest,
CreateTokenResponse,
} from '../services/apiService';
import TokenSidebar from './TokenSidebar';
import dayjs from 'dayjs';
interface TokenWithApp extends StaticToken {
@ -48,7 +49,7 @@ const Tokens: React.FC = () => {
const [applications, setApplications] = useState<Application[]>([]);
const [tokens, setTokens] = useState<TokenWithApp[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [tokenModalOpen, setTokenModalOpen] = useState(false);
const [createdToken, setCreatedToken] = useState<CreateTokenResponse | null>(null);
@ -134,7 +135,7 @@ const Tokens: React.FC = () => {
const { app_id, ...tokenData } = values;
const response = await apiService.createToken(app_id, tokenData);
setCreatedToken(response);
setModalOpen(false);
setSidebarOpen(false);
setTokenModalOpen(true);
form.reset();
loadAllTokens();
@ -237,7 +238,13 @@ const Tokens: React.FC = () => {
));
return (
<Stack gap="lg">
<Stack
gap="lg"
style={{
transition: 'margin-right 0.3s ease',
marginRight: sidebarOpen ? '450px' : '0',
}}
>
<Group justify="space-between">
<div>
<Title order={2} mb="xs">
@ -248,7 +255,7 @@ const Tokens: React.FC = () => {
leftSection={<IconPlus size={16} />}
onClick={() => {
form.reset();
setModalOpen(true);
setSidebarOpen(true);
}}
disabled={applications.length === 0}
>
@ -288,7 +295,7 @@ const Tokens: React.FC = () => {
leftSection={<IconPlus size={16} />}
onClick={() => {
form.reset();
setModalOpen(true);
setSidebarOpen(true);
}}
>
Create Token
@ -314,61 +321,17 @@ const Tokens: React.FC = () => {
</Card>
)}
{/* Create Token Modal */}
<Modal
opened={modalOpen}
<TokenSidebar
opened={sidebarOpen}
onClose={() => {
setModalOpen(false);
setSidebarOpen(false);
form.reset();
}}
title="Create New Token"
size="lg"
>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Select
label="Application"
placeholder="Select an application"
required
data={applications.map(app => ({
value: app.app_id,
label: `${app.app_id} (${app.type.join(', ')})`,
}))}
{...form.getInputProps('app_id')}
/>
<PermissionTree
permissions={form.values.permissions}
onChange={(permissions) => form.setFieldValue('permissions', permissions)}
/>
<TextInput
label="Owner Name"
placeholder="Token owner name"
{...form.getInputProps('owner.name')}
/>
<TextInput
label="Owner Email"
placeholder="owner@example.com"
{...form.getInputProps('owner.owner')}
/>
<Group justify="flex-end" mt="md">
<Button
variant="subtle"
onClick={() => {
setModalOpen(false);
form.reset();
}}
>
Cancel
</Button>
<Button type="submit">Create Token</Button>
</Group>
</Stack>
</form>
</Modal>
onSuccess={() => {
loadAllTokens();
}}
applications={applications}
/>
{/* Token Created Modal */}
<Modal

3
user/web/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
dist
node_modules

View File

@ -24,7 +24,7 @@ import {
} from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import { modals } from '@mantine/modals';
import UserForm from './UserForm';
import UserSidebar from './UserSidebar';
import { userService } from '../services/userService';
import { User, UserStatus, UserRole, ListUsersRequest } from '../types/user';
@ -36,7 +36,7 @@ const UserManagement: React.FC = () => {
const [roleFilter, setRoleFilter] = useState<string | null>(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<User | null>(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 */}
<Stack gap="md">
<Stack
gap="md"
style={{
transition: 'margin-right 0.3s ease',
marginRight: userSidebarOpened ? '400px' : '0',
}}
>
<Group justify="space-between">
<Button leftSection={<IconPlus size={16} />} onClick={handleCreateUser}>
Add User
@ -296,11 +302,11 @@ const UserManagement: React.FC = () => {
</Paper>
</Stack>
{/* User form modal */}
<UserForm
opened={userFormOpened}
onClose={handleUserFormClose}
onSuccess={handleUserFormSuccess}
{/* User sidebar */}
<UserSidebar
opened={userSidebarOpened}
onClose={handleUserSidebarClose}
onSuccess={handleUserSidebarSuccess}
editUser={editingUser}
/>
</>

View File

@ -0,0 +1,255 @@
import React, { useEffect } from 'react';
import {
Paper,
TextInput,
Select,
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 { userService } from '../services/userService';
import { User, CreateUserRequest, UpdateUserRequest, UserRole, UserStatus } from '../types/user';
interface UserSidebarProps {
opened: boolean;
onClose: () => void;
onSuccess: () => void;
editUser?: User | null;
}
const UserSidebar: React.FC<UserSidebarProps> = ({
opened,
onClose,
onSuccess,
editUser,
}) => {
const isEditing = !!editUser;
const form = useForm({
initialValues: {
email: '',
first_name: '',
last_name: '',
display_name: '',
avatar: '',
role: 'user' as UserRole,
status: 'pending' as UserStatus,
},
validate: {
email: (value) => (/^\S+@\S+$/.test(value) ? null : 'Invalid email'),
first_name: (value) => (value.trim().length < 1 ? 'First name is required' : null),
last_name: (value) => (value.trim().length < 1 ? 'Last name is required' : null),
},
});
// Update form values when editUser changes
useEffect(() => {
if (editUser) {
form.setValues({
email: editUser.email || '',
first_name: editUser.first_name || '',
last_name: editUser.last_name || '',
display_name: editUser.display_name || '',
avatar: editUser.avatar || '',
role: editUser.role || 'user' as UserRole,
status: editUser.status || 'pending' as UserStatus,
});
} else {
// Reset to default values when not editing
form.setValues({
email: '',
first_name: '',
last_name: '',
display_name: '',
avatar: '',
role: 'user' as UserRole,
status: 'pending' as UserStatus,
});
}
}, [editUser, opened]);
const handleSubmit = async (values: typeof form.values) => {
try {
if (isEditing && editUser) {
const updateRequest: UpdateUserRequest = {
email: values.email !== editUser.email ? values.email : undefined,
first_name: values.first_name !== editUser.first_name ? values.first_name : undefined,
last_name: values.last_name !== editUser.last_name ? values.last_name : undefined,
display_name: values.display_name !== editUser.display_name ? values.display_name : undefined,
avatar: values.avatar !== editUser.avatar ? values.avatar : undefined,
role: values.role !== editUser.role ? values.role : undefined,
status: values.status !== editUser.status ? values.status : undefined,
};
// Only send fields that have changed
const hasChanges = Object.values(updateRequest).some(value => value !== undefined);
if (!hasChanges) {
notifications.show({
title: 'No Changes',
message: 'No changes detected',
color: 'blue',
});
onClose();
return;
}
await userService.updateUser(editUser.id, updateRequest);
notifications.show({
title: 'Success',
message: 'User updated successfully',
color: 'green',
});
} else {
const createRequest: CreateUserRequest = {
email: values.email,
first_name: values.first_name,
last_name: values.last_name,
display_name: values.display_name || undefined,
avatar: values.avatar || undefined,
role: values.role,
status: values.status,
};
await userService.createUser(createRequest);
notifications.show({
title: 'Success',
message: 'User created successfully',
color: 'green',
});
}
onSuccess();
onClose();
form.reset();
} catch (error: any) {
console.error('Failed to save user:', error);
notifications.show({
title: 'Error',
message: error.message || 'Failed to save user',
color: 'red',
});
}
};
return (
<Paper
style={{
position: 'fixed',
top: 60, // Below header
right: opened ? 0 : '-400px',
bottom: 0,
width: '400px',
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}>
{isEditing ? 'Edit User' : 'Create User'}
</Title>
<ActionIcon
variant="subtle"
color="gray"
onClick={onClose}
>
<IconX size={18} />
</ActionIcon>
</Group>
{/* Content */}
<ScrollArea style={{ flex: 1 }}>
<Box p="md">
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Group grow>
<TextInput
label="First Name"
placeholder="Enter first name"
required
{...form.getInputProps('first_name')}
/>
<TextInput
label="Last Name"
placeholder="Enter last name"
required
{...form.getInputProps('last_name')}
/>
</Group>
<TextInput
label="Display Name"
placeholder="Enter display name (optional)"
{...form.getInputProps('display_name')}
/>
<TextInput
label="Email"
placeholder="Enter email address"
required
type="email"
{...form.getInputProps('email')}
/>
<TextInput
label="Avatar URL"
placeholder="Enter avatar URL (optional)"
{...form.getInputProps('avatar')}
/>
<Group grow>
<Select
label="Role"
placeholder="Select role"
required
data={[
{ value: 'admin', label: 'Admin' },
{ value: 'moderator', label: 'Moderator' },
{ value: 'user', label: 'User' },
{ value: 'viewer', label: 'Viewer' },
]}
{...form.getInputProps('role')}
/>
<Select
label="Status"
placeholder="Select status"
required
data={[
{ value: 'active', label: 'Active' },
{ value: 'inactive', label: 'Inactive' },
{ value: 'suspended', label: 'Suspended' },
{ value: 'pending', label: 'Pending' },
]}
{...form.getInputProps('status')}
/>
</Group>
<Group justify="flex-end" mt="xl">
<Button variant="light" onClick={onClose}>
Cancel
</Button>
<Button type="submit">
{isEditing ? 'Update' : 'Create'} User
</Button>
</Group>
</Stack>
</form>
</Box>
</ScrollArea>
</Paper>
);
};
export default UserSidebar;