-
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@ -1 +1,2 @@
|
||||
dist
|
||||
node_modules
|
||||
|
||||
1
demo/dist/396.js
vendored
1
demo/dist/396.js
vendored
File diff suppressed because one or more lines are too long
2
demo/dist/540.js
vendored
2
demo/dist/540.js
vendored
File diff suppressed because one or more lines are too long
9
demo/dist/540.js.LICENSE.txt
vendored
9
demo/dist/540.js.LICENSE.txt
vendored
@ -1,9 +0,0 @@
|
||||
/**
|
||||
* @license React
|
||||
* react.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
1
demo/dist/665.js
vendored
1
demo/dist/665.js
vendored
@ -1 +0,0 @@
|
||||
"use strict";(self.webpackChunkdemo=self.webpackChunkdemo||[]).push([[665],{665:(e,t,a)=>{a.r(t),a.d(t,{default:()=>c});var r=a(914),l=a.n(r),n=a(435),i=a(101);const c=()=>{const[e,t]=(0,r.useState)(0),[a,c]=(0,r.useState)(!1);(0,r.useEffect)(()=>{const e=setInterval(()=>{t(e=>e>=100?0:e+1)},100);return()=>clearInterval(e)},[]);const o=[{label:"Active Users",value:"1,234",icon:i.IconUsers,color:"blue"},{label:"Total Revenue",value:"$45,678",icon:i.IconChartLine,color:"green"},{label:"Projects",value:"89",icon:i.IconRocket,color:"orange"}];return l().createElement(n.Container,{size:"xl",py:"xl"},l().createElement(n.Stack,{gap:"xl"},l().createElement(n.Group,{justify:"space-between",align:"center"},l().createElement("div",null,l().createElement(n.Title,{order:1},"Demo Application"),l().createElement(n.Text,{c:"dimmed",size:"lg",mt:"xs"},"A sample federated application showcasing module federation")),l().createElement(n.ActionIcon,{size:"lg",variant:"light",loading:a,onClick:()=>{c(!0),setTimeout(()=>c(!1),1500)}},l().createElement(i.IconRefresh,{size:18}))),l().createElement(n.Alert,{icon:l().createElement(i.IconInfoCircle,{size:16}),title:"Welcome!",color:"blue",variant:"light"},"This is a demo application loaded via Module Federation. It demonstrates how microfrontends can be seamlessly integrated into the shell application."),l().createElement(n.SimpleGrid,{cols:{base:1,sm:3},spacing:"md"},o.map(e=>l().createElement(n.Paper,{key:e.label,p:"md",radius:"md",withBorder:!0},l().createElement(n.Group,{justify:"space-between"},l().createElement("div",null,l().createElement(n.Text,{c:"dimmed",size:"sm",fw:500,tt:"uppercase"},e.label),l().createElement(n.Text,{fw:700,size:"xl"},e.value)),l().createElement(e.icon,{size:24,color:`var(--mantine-color-${e.color}-6)`}))))),l().createElement(n.Card,{shadow:"sm",padding:"lg",radius:"md",withBorder:!0},l().createElement(n.Card.Section,{withBorder:!0,inheritPadding:!0,py:"xs"},l().createElement(n.Group,{justify:"space-between"},l().createElement(n.Text,{fw:500},"System Performance"),l().createElement(n.Badge,{color:"green",variant:"light"},"Healthy"))),l().createElement(n.Card.Section,{inheritPadding:!0,py:"md"},l().createElement(n.Stack,{gap:"xs"},l().createElement(n.Text,{size:"sm",c:"dimmed"},"CPU Usage: ",e.toFixed(1),"%"),l().createElement(n.Progress,{value:e,size:"sm",color:"blue",animated:!0})))),l().createElement("div",null,l().createElement(n.Title,{order:2,mb:"md"},"Features"),l().createElement(n.SimpleGrid,{cols:{base:1,sm:2},spacing:"md"},[{title:"Real-time Analytics",description:"Monitor your data in real-time"},{title:"Team Collaboration",description:"Work together seamlessly"},{title:"Cloud Integration",description:"Connect with cloud services"},{title:"Custom Reports",description:"Generate detailed reports"}].map((e,t)=>l().createElement(n.Card,{key:t,shadow:"sm",padding:"lg",radius:"md",withBorder:!0},l().createElement(n.Group,{mb:"xs"},l().createElement(i.IconCheck,{size:16,color:"var(--mantine-color-green-6)"}),l().createElement(n.Text,{fw:500},e.title)),l().createElement(n.Text,{size:"sm",c:"dimmed"},e.description))))),l().createElement(n.Divider,null),l().createElement(n.Group,{justify:"center"},l().createElement(n.Button,{variant:"outline",size:"md"},"View Documentation"),l().createElement(n.Button,{size:"md"},"Get Started"))))}}}]);
|
||||
2
demo/dist/81.js
vendored
2
demo/dist/81.js
vendored
File diff suppressed because one or more lines are too long
9
demo/dist/81.js.LICENSE.txt
vendored
9
demo/dist/81.js.LICENSE.txt
vendored
@ -1,9 +0,0 @@
|
||||
/**
|
||||
* @license React
|
||||
* react-jsx-runtime.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
1
demo/dist/870.js
vendored
1
demo/dist/870.js
vendored
File diff suppressed because one or more lines are too long
2
demo/dist/961.js
vendored
2
demo/dist/961.js
vendored
File diff suppressed because one or more lines are too long
19
demo/dist/961.js.LICENSE.txt
vendored
19
demo/dist/961.js.LICENSE.txt
vendored
@ -1,19 +0,0 @@
|
||||
/**
|
||||
* @license React
|
||||
* react-dom.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* scheduler.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
1
demo/dist/index.html
vendored
1
demo/dist/index.html
vendored
@ -1 +0,0 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Demo App</title><script defer="defer" src="main.js"></script><script defer="defer" src="remoteEntry.js"></script></head><body><div id="root"></div></body></html>
|
||||
1
demo/dist/main.js
vendored
1
demo/dist/main.js
vendored
File diff suppressed because one or more lines are too long
1
demo/dist/remoteEntry.js
vendored
1
demo/dist/remoteEntry.js
vendored
File diff suppressed because one or more lines are too long
@ -12,7 +12,11 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"@mantine/core": "^7.0.0",
|
||||
"@mantine/hooks": "^7.0.0",
|
||||
"@tabler/icons-react": "^2.40.0"
|
||||
"@mantine/notifications": "^7.0.0",
|
||||
"@mantine/form": "^7.0.0",
|
||||
"@mantine/modals": "^7.0.0",
|
||||
"@tabler/icons-react": "^2.40.0",
|
||||
"@skybridge/web-components": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.12",
|
||||
|
||||
121
demo/src/App.tsx
121
demo/src/App.tsx
@ -22,11 +22,25 @@ import {
|
||||
IconRefresh,
|
||||
IconCheck,
|
||||
IconInfoCircle,
|
||||
IconPlus,
|
||||
} from '@tabler/icons-react';
|
||||
import {
|
||||
DataTable,
|
||||
TableColumn,
|
||||
FormSidebar,
|
||||
FormField
|
||||
} from '@skybridge/web-components';
|
||||
|
||||
const DemoApp: React.FC = () => {
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showTable, setShowTable] = useState(false);
|
||||
const [sidebarOpened, setSidebarOpened] = useState(false);
|
||||
const [demoData, setDemoData] = useState([
|
||||
{ id: '1', name: 'John Doe', email: 'john@example.com', status: 'active', role: 'admin', created_at: '2024-01-15' },
|
||||
{ id: '2', name: 'Jane Smith', email: 'jane@example.com', status: 'active', role: 'user', created_at: '2024-02-20' },
|
||||
{ id: '3', name: 'Bob Johnson', email: 'bob@example.com', status: 'inactive', role: 'viewer', created_at: '2024-03-10' },
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
@ -53,6 +67,63 @@ const DemoApp: React.FC = () => {
|
||||
{ title: 'Custom Reports', description: 'Generate detailed reports' },
|
||||
];
|
||||
|
||||
const tableColumns: TableColumn[] = [
|
||||
{ key: 'name', label: 'Name', sortable: true },
|
||||
{ key: 'email', label: 'Email', sortable: true },
|
||||
{ key: 'role', label: 'Role', render: (value) => <Badge variant="light" size="sm">{value}</Badge> },
|
||||
{ key: 'status', label: 'Status' }, // Uses default status rendering
|
||||
{ key: 'created_at', label: 'Created', render: (value) => new Date(value).toLocaleDateString() },
|
||||
];
|
||||
|
||||
const formFields: FormField[] = [
|
||||
{ name: 'name', label: 'Full Name', type: 'text', required: true, placeholder: 'Enter full name' },
|
||||
{ name: 'email', label: 'Email', type: 'email', required: true, placeholder: 'Enter email address', validation: { email: true } },
|
||||
{
|
||||
name: 'role',
|
||||
label: 'Role',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'admin', label: 'Admin' },
|
||||
{ value: 'user', label: 'User' },
|
||||
{ value: 'viewer', label: 'Viewer' },
|
||||
],
|
||||
defaultValue: 'user'
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
label: 'Status',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'inactive', label: 'Inactive' },
|
||||
],
|
||||
defaultValue: 'active'
|
||||
},
|
||||
];
|
||||
|
||||
const handleFormSubmit = async (values: any) => {
|
||||
// Simulate API call
|
||||
console.log('Form submitted:', values);
|
||||
const newItem = {
|
||||
id: Date.now().toString(),
|
||||
...values,
|
||||
created_at: new Date().toISOString().split('T')[0]
|
||||
};
|
||||
setDemoData([...demoData, newItem]);
|
||||
};
|
||||
|
||||
const handleEdit = (item: any) => {
|
||||
console.log('Edit item:', item);
|
||||
// Would normally open form with item data
|
||||
setSidebarOpened(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (item: any) => {
|
||||
setDemoData(demoData.filter(d => d.id !== item.id));
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="xl" py="xl">
|
||||
<Stack gap="xl">
|
||||
@ -135,6 +206,43 @@ const DemoApp: React.FC = () => {
|
||||
|
||||
<Divider />
|
||||
|
||||
<div>
|
||||
<Title order={2} mb="md">Shared Components Demo</Title>
|
||||
<Text c="dimmed" mb="lg">
|
||||
Demonstration of shared components from @skybridge/web-components
|
||||
</Text>
|
||||
|
||||
<Group mb="md">
|
||||
<Button
|
||||
leftSection={<IconPlus size={16} />}
|
||||
onClick={() => setShowTable(!showTable)}
|
||||
>
|
||||
{showTable ? 'Hide' : 'Show'} DataTable Demo
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSidebarOpened(true)}
|
||||
>
|
||||
Show FormSidebar Demo
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{showTable && (
|
||||
<DataTable
|
||||
data={demoData}
|
||||
columns={tableColumns}
|
||||
title="Demo User Management"
|
||||
searchable
|
||||
onAdd={() => setSidebarOpened(true)}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
emptyMessage="No demo data available"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Group justify="center">
|
||||
<Button variant="outline" size="md">
|
||||
View Documentation
|
||||
@ -144,6 +252,19 @@ const DemoApp: React.FC = () => {
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<FormSidebar
|
||||
opened={sidebarOpened}
|
||||
onClose={() => setSidebarOpened(false)}
|
||||
onSuccess={() => {
|
||||
setSidebarOpened(false);
|
||||
setShowTable(true); // Show table after successful form submission
|
||||
}}
|
||||
title="Demo User"
|
||||
fields={formFields}
|
||||
onSubmit={handleFormSubmit}
|
||||
width={400}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
"@mantine/dates": "^7.0.0",
|
||||
"@mantine/form": "^7.0.0",
|
||||
"@mantine/hooks": "^7.0.0",
|
||||
"@mantine/modals": "^7.0.0",
|
||||
"@mantine/notifications": "^7.0.0",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@tabler/icons-react": "^2.40.0",
|
||||
@ -16,7 +17,8 @@
|
||||
"monaco-editor": "^0.52.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.8.0"
|
||||
"react-router-dom": "^6.8.0",
|
||||
"@skybridge/web-components": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.22.0",
|
||||
|
||||
@ -1,31 +1,16 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Stack,
|
||||
Title,
|
||||
Group,
|
||||
ActionIcon,
|
||||
import {
|
||||
DataTable,
|
||||
TableColumn,
|
||||
Badge,
|
||||
Card,
|
||||
Group,
|
||||
Text,
|
||||
Loader,
|
||||
Alert,
|
||||
Tooltip,
|
||||
Menu,
|
||||
} from '@mantine/core';
|
||||
Stack
|
||||
} from '@skybridge/web-components';
|
||||
import {
|
||||
IconPlayerPlay,
|
||||
IconSettings,
|
||||
IconTrash,
|
||||
IconRocket,
|
||||
IconCode,
|
||||
IconDots,
|
||||
IconPlus,
|
||||
IconRefresh,
|
||||
IconExclamationCircle,
|
||||
} from '@tabler/icons-react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { functionApi } from '../services/apiService';
|
||||
import { FunctionDefinition } from '../types';
|
||||
|
||||
@ -48,12 +33,10 @@ export const FunctionList: React.FC<FunctionListProps> = ({
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await functionApi.list();
|
||||
// Ensure we have a valid array
|
||||
const functionsArray = response.data?.functions || [];
|
||||
setFunctions(functionsArray);
|
||||
} catch (err) {
|
||||
console.error('Failed to load functions:', err);
|
||||
const data = await functionApi.listFunctions();
|
||||
setFunctions(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load functions:', error);
|
||||
setError('Failed to load functions');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@ -65,229 +48,78 @@ export const FunctionList: React.FC<FunctionListProps> = ({
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (func: FunctionDefinition) => {
|
||||
if (!confirm(`Are you sure you want to delete function "${func.name}"?`)) {
|
||||
return;
|
||||
}
|
||||
await functionApi.deleteFunction(func.id);
|
||||
loadFunctions();
|
||||
};
|
||||
|
||||
try {
|
||||
await functionApi.delete(func.id);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: `Function "${func.name}" deleted successfully`,
|
||||
color: 'green',
|
||||
});
|
||||
loadFunctions();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete function:', err);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: `Failed to delete function "${func.name}"`,
|
||||
color: 'red',
|
||||
});
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active': return 'green';
|
||||
case 'inactive': return 'gray';
|
||||
case 'error': return 'red';
|
||||
case 'building': return 'yellow';
|
||||
default: return 'blue';
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeploy = async (func: FunctionDefinition) => {
|
||||
try {
|
||||
await functionApi.deploy(func.id);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: `Function "${func.name}" deployed successfully`,
|
||||
color: 'green',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to deploy function:', err);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: `Failed to deploy function "${func.name}"`,
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getRuntimeColor = (runtime: string) => {
|
||||
switch (runtime) {
|
||||
case 'nodejs18': return 'green';
|
||||
case 'python3.9': return 'blue';
|
||||
case 'go1.20': return 'cyan';
|
||||
default: return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && functions.length === 0) {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between">
|
||||
<Title order={2}>Functions</Title>
|
||||
<Group>
|
||||
<Button
|
||||
leftSection={<IconRefresh size={16} />}
|
||||
onClick={loadFunctions}
|
||||
loading={loading}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<IconPlus size={16} />}
|
||||
onClick={onCreateFunction}
|
||||
>
|
||||
Create Function
|
||||
</Button>
|
||||
</Group>
|
||||
const columns: TableColumn[] = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Function Name',
|
||||
sortable: true,
|
||||
render: (value, func: FunctionDefinition) => (
|
||||
<Group gap="xs">
|
||||
<IconCode size={16} />
|
||||
<Text fw={500}>{value}</Text>
|
||||
</Group>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'runtime',
|
||||
label: 'Runtime',
|
||||
render: (value) => (
|
||||
<Badge variant="light" size="sm">{value}</Badge>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
render: (value) => (
|
||||
<Badge color={getStatusColor(value)} size="sm">{value}</Badge>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
label: 'Created',
|
||||
render: (value) => new Date(value).toLocaleDateString()
|
||||
},
|
||||
];
|
||||
|
||||
<Stack align="center" justify="center" h={200}>
|
||||
<Loader size="lg" />
|
||||
<Text>Loading functions...</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
const customActions = [
|
||||
{
|
||||
key: 'execute',
|
||||
label: 'Execute',
|
||||
icon: <IconPlayerPlay size={14} />,
|
||||
onClick: (func: FunctionDefinition) => onExecuteFunction(func),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between">
|
||||
<Title order={2}>Functions</Title>
|
||||
<Group>
|
||||
<Button
|
||||
leftSection={<IconRefresh size={16} />}
|
||||
onClick={loadFunctions}
|
||||
loading={loading}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<IconPlus size={16} />}
|
||||
onClick={onCreateFunction}
|
||||
>
|
||||
Create Function
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{error && (
|
||||
<Alert color="red" title="Error">
|
||||
{error}
|
||||
<Button variant="light" size="xs" mt="sm" onClick={loadFunctions}>
|
||||
Retry
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{functions.length === 0 ? (
|
||||
<Card shadow="sm" radius="md" withBorder p="xl">
|
||||
<Stack align="center" gap="md">
|
||||
<IconCode size={48} color="gray" />
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Text fw={500} mb="xs">
|
||||
No functions found
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Create your first serverless function to get started
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
leftSection={<IconPlus size={16} />}
|
||||
onClick={onCreateFunction}
|
||||
>
|
||||
Create Function
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
) : (
|
||||
<Card shadow="sm" radius="md" withBorder>
|
||||
<Table>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Runtime</Table.Th>
|
||||
<Table.Th>Image</Table.Th>
|
||||
<Table.Th>Memory</Table.Th>
|
||||
<Table.Th>Timeout</Table.Th>
|
||||
<Table.Th>Owner</Table.Th>
|
||||
<Table.Th>Created</Table.Th>
|
||||
<Table.Th>Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{functions.map((func) => (
|
||||
<Table.Tr key={func.id}>
|
||||
<Table.Td>
|
||||
<Text fw={500}>{func.name}</Text>
|
||||
<Text size="xs" c="dimmed">{func.description || ''}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={getRuntimeColor(func.runtime)} variant="light">
|
||||
{func.runtime}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{func.image || ''}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{func.memoryLimit || 'N/A'} MB</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{func.timeout || 'N/A'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">N/A</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">
|
||||
{func.createdAt ? new Date(func.createdAt).toLocaleDateString() : 'N/A'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<Tooltip label="Execute Function">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="green"
|
||||
size="sm"
|
||||
onClick={() => onExecuteFunction(func)}
|
||||
>
|
||||
<IconPlayerPlay size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" size="sm">
|
||||
<IconDots size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconSettings size={16} />}
|
||||
onClick={() => onEditFunction(func)}
|
||||
>
|
||||
Edit
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconRocket size={16} />}
|
||||
onClick={() => handleDeploy(func)}
|
||||
>
|
||||
Deploy
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconTrash size={16} />}
|
||||
color="red"
|
||||
onClick={() => handleDelete(func)}
|
||||
>
|
||||
Delete
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
<Stack gap="md">
|
||||
<DataTable
|
||||
data={functions}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
error={error}
|
||||
title="Functions"
|
||||
searchable
|
||||
onAdd={onCreateFunction}
|
||||
onEdit={onEditFunction}
|
||||
onDelete={handleDelete}
|
||||
onRefresh={loadFunctions}
|
||||
customActions={customActions}
|
||||
emptyMessage="No functions found"
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
};
|
||||
@ -1,23 +1,16 @@
|
||||
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 {
|
||||
FormSidebar,
|
||||
FormField
|
||||
} from '@skybridge/web-components';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import { functionApi, runtimeApi } from '../services/apiService';
|
||||
import { FunctionDefinition, CreateFunctionRequest, UpdateFunctionRequest, RuntimeType } from '../types';
|
||||
@ -35,9 +28,9 @@ export const FunctionSidebar: React.FC<FunctionSidebarProps> = ({
|
||||
onSuccess,
|
||||
editFunction,
|
||||
}) => {
|
||||
const isEditing = !!editFunction;
|
||||
const [runtimeOptions, setRuntimeOptions] = useState<Array<{value: string; label: string}>>([]);
|
||||
|
||||
const [codeContent, setCodeContent] = useState('');
|
||||
|
||||
// Default images for each runtime
|
||||
const DEFAULT_IMAGES: Record<string, string> = {
|
||||
'nodejs18': 'node:18-alpine',
|
||||
@ -88,30 +81,20 @@ 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)
|
||||
func Handler(ctx context.Context, event Event) (map[string]interface{}, error) {
|
||||
fmt.Printf("Event: %+v\\n", event)
|
||||
|
||||
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),
|
||||
return map[string]interface{}{
|
||||
"statusCode": 200,
|
||||
"body": map[string]interface{}{
|
||||
"message": "Hello from Go!",
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}, nil
|
||||
}`
|
||||
};
|
||||
@ -119,196 +102,110 @@ func Handler(ctx context.Context, event Event) (Response, error) {
|
||||
};
|
||||
|
||||
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' },
|
||||
]);
|
||||
}
|
||||
loadRuntimeOptions();
|
||||
if (editFunction) {
|
||||
setCodeContent(editFunction.code || '');
|
||||
}
|
||||
}, [editFunction]);
|
||||
|
||||
const loadRuntimeOptions = async () => {
|
||||
try {
|
||||
const runtimes = await runtimeApi.listRuntimes();
|
||||
const options = runtimes.map((runtime: RuntimeType) => ({
|
||||
value: runtime.name,
|
||||
label: `${runtime.name} (${runtime.version})`
|
||||
}));
|
||||
setRuntimeOptions(options);
|
||||
} catch (error) {
|
||||
console.error('Failed to load runtimes:', error);
|
||||
// Fallback options
|
||||
setRuntimeOptions([
|
||||
{ value: 'nodejs18', label: 'Node.js 18' },
|
||||
{ value: 'python3.9', label: 'Python 3.9' },
|
||||
{ value: 'go1.20', label: 'Go 1.20' }
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
const fields: FormField[] = [
|
||||
{
|
||||
name: 'name',
|
||||
label: 'Function Name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: 'my-function',
|
||||
validation: { pattern: /^[a-z0-9-]+$/ },
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
label: 'Description',
|
||||
type: 'textarea',
|
||||
required: false,
|
||||
placeholder: 'Function description...',
|
||||
},
|
||||
{
|
||||
name: 'runtime',
|
||||
label: 'Runtime',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: runtimeOptions,
|
||||
defaultValue: 'nodejs18',
|
||||
},
|
||||
{
|
||||
name: 'timeout',
|
||||
label: 'Timeout (seconds)',
|
||||
type: 'number',
|
||||
required: true,
|
||||
defaultValue: 30,
|
||||
},
|
||||
{
|
||||
name: 'memory',
|
||||
label: 'Memory (MB)',
|
||||
type: 'number',
|
||||
required: true,
|
||||
defaultValue: 128,
|
||||
},
|
||||
{
|
||||
name: 'environment_variables',
|
||||
label: 'Environment Variables',
|
||||
type: 'json',
|
||||
required: false,
|
||||
defaultValue: '{}',
|
||||
},
|
||||
];
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
const submitData = {
|
||||
...values,
|
||||
code: codeContent,
|
||||
docker_image: DEFAULT_IMAGES[values.runtime] || DEFAULT_IMAGES['nodejs18'],
|
||||
};
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
const updateRequest: UpdateFunctionRequest = {
|
||||
description: submitData.description,
|
||||
code: submitData.code,
|
||||
timeout: submitData.timeout,
|
||||
memory: submitData.memory,
|
||||
environment_variables: submitData.environment_variables,
|
||||
docker_image: submitData.docker_image,
|
||||
};
|
||||
await functionApi.updateFunction(editFunction.id, updateRequest);
|
||||
} 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',
|
||||
});
|
||||
const createRequest: CreateFunctionRequest = submitData;
|
||||
await functionApi.createFunction(createRequest);
|
||||
}
|
||||
};
|
||||
|
||||
// Create a custom sidebar that includes the Monaco editor
|
||||
return (
|
||||
<Paper
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 60, // Below header
|
||||
right: opened ? 0 : '-500px',
|
||||
top: 60,
|
||||
right: opened ? 0 : '-600px',
|
||||
bottom: 0,
|
||||
width: '500px',
|
||||
width: '600px',
|
||||
zIndex: 1000,
|
||||
borderRadius: 0,
|
||||
display: 'flex',
|
||||
@ -318,184 +215,41 @@ func Handler(ctx context.Context, event Event) (Response, error) {
|
||||
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')}
|
||||
<Stack gap="md" p="md">
|
||||
<FormSidebar
|
||||
opened={true} // Always open since we're embedding it
|
||||
onClose={() => {}} // Handled by parent
|
||||
onSuccess={onSuccess}
|
||||
title="Function"
|
||||
editMode={!!editFunction}
|
||||
editItem={editFunction}
|
||||
fields={fields}
|
||||
onSubmit={handleSubmit}
|
||||
width={600}
|
||||
style={{ position: 'relative', right: 'auto', top: 'auto', bottom: 'auto' }}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Text fw={500} mb="sm">Code Editor</Text>
|
||||
<Box h={300} style={{ border: '1px solid var(--mantine-color-gray-3)' }}>
|
||||
<Editor
|
||||
height="300px"
|
||||
language={getEditorLanguage(editFunction?.runtime || 'nodejs18')}
|
||||
value={codeContent}
|
||||
onChange={(value) => setCodeContent(value || '')}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
|
||||
<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>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
2
kms/web/dist/665.js
vendored
2
kms/web/dist/665.js
vendored
File diff suppressed because one or more lines are too long
2
kms/web/dist/main.js
vendored
2
kms/web/dist/main.js
vendored
File diff suppressed because one or more lines are too long
10
kms/web/dist/main.js.LICENSE.txt
vendored
10
kms/web/dist/main.js.LICENSE.txt
vendored
@ -1,3 +1,13 @@
|
||||
/**
|
||||
* @license React
|
||||
* react-jsx-runtime.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @remix-run/router v1.23.0
|
||||
*
|
||||
|
||||
2
kms/web/dist/remoteEntry.js
vendored
2
kms/web/dist/remoteEntry.js
vendored
File diff suppressed because one or more lines are too long
@ -16,9 +16,11 @@
|
||||
"@mantine/notifications": "^7.0.0",
|
||||
"@mantine/dates": "^7.0.0",
|
||||
"@mantine/form": "^7.0.0",
|
||||
"@mantine/modals": "^7.0.0",
|
||||
"@tabler/icons-react": "^2.40.0",
|
||||
"axios": "^1.11.0",
|
||||
"dayjs": "^1.11.13"
|
||||
"dayjs": "^1.11.13",
|
||||
"@skybridge/web-components": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.12",
|
||||
|
||||
@ -1,19 +1,8 @@
|
||||
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 React from 'react';
|
||||
import {
|
||||
FormSidebar,
|
||||
FormField
|
||||
} from '@skybridge/web-components';
|
||||
import { apiService, Application, CreateApplicationRequest } from '../services/apiService';
|
||||
|
||||
interface ApplicationSidebarProps {
|
||||
@ -29,35 +18,6 @@ const ApplicationSidebar: React.FC<ApplicationSidebarProps> = ({
|
||||
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]?)$/);
|
||||
@ -74,167 +34,97 @@ const ApplicationSidebar: React.FC<ApplicationSidebarProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Update form values when editingApp changes
|
||||
useEffect(() => {
|
||||
const fields: FormField[] = [
|
||||
{
|
||||
name: 'app_id',
|
||||
label: 'Application ID',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: 'my-app-id',
|
||||
disabled: !!editingApp, // Disable editing for existing apps
|
||||
},
|
||||
{
|
||||
name: 'app_link',
|
||||
label: 'Application Link',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: 'https://myapp.example.com',
|
||||
validation: { url: true },
|
||||
},
|
||||
{
|
||||
name: 'type',
|
||||
label: 'Application Type',
|
||||
type: 'multiselect',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'static', label: 'Static Token App' },
|
||||
{ value: 'user', label: 'User Token App' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'callback_url',
|
||||
label: 'Callback URL',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: 'https://myapp.example.com/callback',
|
||||
validation: { url: true },
|
||||
},
|
||||
{
|
||||
name: 'token_prefix',
|
||||
label: 'Token Prefix (Optional)',
|
||||
type: 'text',
|
||||
required: false,
|
||||
placeholder: 'myapp_',
|
||||
},
|
||||
{
|
||||
name: 'token_renewal_duration',
|
||||
label: 'Token Renewal Duration',
|
||||
type: 'text',
|
||||
required: false,
|
||||
placeholder: '24h',
|
||||
defaultValue: '24h',
|
||||
},
|
||||
{
|
||||
name: 'max_token_duration',
|
||||
label: 'Max Token Duration',
|
||||
type: 'text',
|
||||
required: false,
|
||||
placeholder: '168h',
|
||||
defaultValue: '168h',
|
||||
},
|
||||
];
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
const submitData = {
|
||||
...values,
|
||||
token_renewal_duration_seconds: parseDuration(values.token_renewal_duration || '24h'),
|
||||
max_token_duration_seconds: parseDuration(values.max_token_duration || '168h'),
|
||||
owner: {
|
||||
type: 'individual',
|
||||
name: 'Admin User',
|
||||
owner: 'admin@example.com',
|
||||
},
|
||||
};
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
await apiService.updateApplication(editingApp.app_id, submitData);
|
||||
} 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',
|
||||
});
|
||||
await apiService.createApplication(submitData);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
<FormSidebar
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
onSuccess={onSuccess}
|
||||
title="Application"
|
||||
editMode={!!editingApp}
|
||||
editItem={editingApp}
|
||||
fields={fields}
|
||||
onSubmit={handleSubmit}
|
||||
width={450}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -1,34 +1,15 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Stack,
|
||||
Title,
|
||||
Modal,
|
||||
TextInput,
|
||||
MultiSelect,
|
||||
Group,
|
||||
ActionIcon,
|
||||
import {
|
||||
DataTable,
|
||||
TableColumn,
|
||||
Badge,
|
||||
Card,
|
||||
Group,
|
||||
Text,
|
||||
Loader,
|
||||
Alert,
|
||||
Textarea,
|
||||
Select,
|
||||
NumberInput,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconPlus,
|
||||
IconEdit,
|
||||
IconTrash,
|
||||
IconEye,
|
||||
IconCopy,
|
||||
IconAlertCircle,
|
||||
} from '@tabler/icons-react';
|
||||
import { useForm } from '@mantine/form';
|
||||
Stack
|
||||
} from '@skybridge/web-components';
|
||||
import { IconEye, IconCopy } from '@tabler/icons-react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { apiService, Application, CreateApplicationRequest } from '../services/apiService';
|
||||
import { apiService, Application } from '../services/apiService';
|
||||
import ApplicationSidebar from './ApplicationSidebar';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
@ -37,30 +18,6 @@ const Applications: React.FC = () => {
|
||||
const [loading, setLoading] = 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);
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadApplications();
|
||||
@ -73,109 +30,30 @@ const Applications: React.FC = () => {
|
||||
setApplications(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load applications:', error);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to load applications',
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: CreateApplicationRequest) => {
|
||||
try {
|
||||
// Convert duration strings to seconds for API
|
||||
const apiValues = {
|
||||
...values,
|
||||
token_renewal_duration: parseDuration(values.token_renewal_duration),
|
||||
max_token_duration: parseDuration(values.max_token_duration),
|
||||
};
|
||||
|
||||
if (editingApp) {
|
||||
await apiService.updateApplication(editingApp.app_id, apiValues);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Application updated successfully',
|
||||
color: 'green',
|
||||
});
|
||||
} else {
|
||||
await apiService.createApplication(apiValues);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Application created successfully',
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
setSidebarOpen(false);
|
||||
setEditingApp(null);
|
||||
form.reset();
|
||||
loadApplications();
|
||||
} catch (error) {
|
||||
console.error('Failed to save application:', error);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to save application',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
const handleAdd = () => {
|
||||
setEditingApp(null);
|
||||
setSidebarOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (app: Application) => {
|
||||
setEditingApp(app);
|
||||
form.setValues({
|
||||
app_id: app.app_id,
|
||||
app_link: app.app_link,
|
||||
type: app.type,
|
||||
callback_url: app.callback_url,
|
||||
token_prefix: app.token_prefix || '',
|
||||
token_renewal_duration: `${app.token_renewal_duration / 3600}h`,
|
||||
max_token_duration: `${app.max_token_duration / 3600}h`,
|
||||
owner: app.owner,
|
||||
});
|
||||
setSidebarOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (appId: string) => {
|
||||
if (window.confirm('Are you sure you want to delete this application?')) {
|
||||
try {
|
||||
await apiService.deleteApplication(appId);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Application deleted successfully',
|
||||
color: 'green',
|
||||
});
|
||||
loadApplications();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete application:', error);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to delete application',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
}
|
||||
const handleDelete = async (app: Application) => {
|
||||
await apiService.deleteApplication(app.app_id);
|
||||
loadApplications();
|
||||
};
|
||||
|
||||
const handleViewDetails = (app: Application) => {
|
||||
setSelectedApp(app);
|
||||
setDetailModalOpen(true);
|
||||
const handleSuccess = () => {
|
||||
setSidebarOpen(false);
|
||||
setEditingApp(null);
|
||||
loadApplications();
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
@ -187,216 +65,85 @@ const Applications: React.FC = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const appTypeOptions = [
|
||||
{ value: 'static', label: 'Static' },
|
||||
{ value: 'user', label: 'User' },
|
||||
];
|
||||
|
||||
const rows = applications.map((app) => (
|
||||
<Table.Tr key={app.app_id}>
|
||||
<Table.Td>
|
||||
<Text fw={500}>{app.app_id}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
const columns: TableColumn[] = [
|
||||
{
|
||||
key: 'app_id',
|
||||
label: 'Application ID',
|
||||
render: (value) => <Text fw={500}>{value}</Text>
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
label: 'Type',
|
||||
render: (value: string[]) => (
|
||||
<Group gap="xs">
|
||||
{app.type.map((type) => (
|
||||
{value.map((type) => (
|
||||
<Badge key={type} variant="light" size="sm">
|
||||
{type}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'owner',
|
||||
label: 'Owner',
|
||||
render: (value: any) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{app.owner.name} ({app.owner.owner})
|
||||
{value.name} ({value.owner})
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
label: 'Created',
|
||||
render: (value) => (
|
||||
<Text size="sm">
|
||||
{dayjs(app.created_at).format('MMM DD, YYYY')}
|
||||
{dayjs(value).format('MMM DD, YYYY')}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
onClick={() => handleViewDetails(app)}
|
||||
title="View Details"
|
||||
>
|
||||
<IconEye size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => handleEdit(app)}
|
||||
title="Edit"
|
||||
>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => handleDelete(app.app_id)}
|
||||
title="Delete"
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
));
|
||||
)
|
||||
},
|
||||
];
|
||||
|
||||
const customActions = [
|
||||
{
|
||||
key: 'view',
|
||||
label: 'View Details',
|
||||
icon: <IconEye size={14} />,
|
||||
onClick: (app: Application) => {
|
||||
// Could open a modal or navigate to details page
|
||||
console.log('View details for:', app.app_id);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'copy',
|
||||
label: 'Copy App ID',
|
||||
icon: <IconCopy size={14} />,
|
||||
onClick: (app: Application) => copyToClipboard(app.app_id),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack
|
||||
gap="lg"
|
||||
style={{
|
||||
transition: 'margin-right 0.3s ease',
|
||||
marginRight: sidebarOpen ? '450px' : '0',
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Title order={2} mb="xs">
|
||||
Applications
|
||||
</Title>
|
||||
</div>
|
||||
<Button
|
||||
leftSection={<IconPlus size={16} />}
|
||||
onClick={() => {
|
||||
setEditingApp(null);
|
||||
form.reset();
|
||||
setSidebarOpen(true);
|
||||
}}
|
||||
>
|
||||
New Application
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{loading ? (
|
||||
<Stack align="center" justify="center" h={200}>
|
||||
<Loader size="lg" />
|
||||
<Text>Loading applications...</Text>
|
||||
</Stack>
|
||||
) : applications.length === 0 ? (
|
||||
<Card shadow="sm" radius="md" withBorder p="xl">
|
||||
<Stack align="center" gap="md">
|
||||
<IconAlertCircle size={48} color="gray" />
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Text fw={500} mb="xs">
|
||||
No applications found
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Create your first application to get started with the key management system
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
leftSection={<IconPlus size={16} />}
|
||||
onClick={() => {
|
||||
setEditingApp(null);
|
||||
form.reset();
|
||||
setSidebarOpen(true);
|
||||
}}
|
||||
>
|
||||
Create Application
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
) : (
|
||||
<Card shadow="sm" radius="md" withBorder>
|
||||
<Table>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Application ID</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Owner</Table.Th>
|
||||
<Table.Th>Created</Table.Th>
|
||||
<Table.Th>Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>{rows}</Table.Tbody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Stack gap="md">
|
||||
<DataTable
|
||||
data={applications}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
title="Applications"
|
||||
searchable
|
||||
onAdd={handleAdd}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onRefresh={loadApplications}
|
||||
customActions={customActions}
|
||||
emptyMessage="No applications found"
|
||||
/>
|
||||
|
||||
<ApplicationSidebar
|
||||
opened={sidebarOpen}
|
||||
onClose={() => {
|
||||
setSidebarOpen(false);
|
||||
setEditingApp(null);
|
||||
}}
|
||||
onSuccess={() => {
|
||||
loadApplications();
|
||||
}}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
onSuccess={handleSuccess}
|
||||
editingApp={editingApp}
|
||||
/>
|
||||
|
||||
{/* Detail Modal */}
|
||||
<Modal
|
||||
opened={detailModalOpen}
|
||||
onClose={() => setDetailModalOpen(false)}
|
||||
title="Application Details"
|
||||
size="md"
|
||||
>
|
||||
{selectedApp && (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text fw={500}>Application ID:</Text>
|
||||
<Group gap="xs">
|
||||
<Text>{selectedApp.app_id}</Text>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
onClick={() => copyToClipboard(selectedApp.app_id)}
|
||||
>
|
||||
<IconCopy size={12} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text fw={500}>HMAC Key:</Text>
|
||||
<Group gap="xs">
|
||||
<Text size="sm" style={{ fontFamily: 'monospace' }}>
|
||||
{selectedApp.hmac_key.substring(0, 16)}...
|
||||
</Text>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
onClick={() => copyToClipboard(selectedApp.hmac_key)}
|
||||
>
|
||||
<IconCopy size={12} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text fw={500}>Application Link:</Text>
|
||||
<Text size="sm">{selectedApp.app_link}</Text>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text fw={500}>Callback URL:</Text>
|
||||
<Text size="sm">{selectedApp.callback_url}</Text>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text fw={500}>Token Renewal:</Text>
|
||||
<Text size="sm">{selectedApp.token_renewal_duration / 3600}h</Text>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text fw={500}>Max Duration:</Text>
|
||||
<Text size="sm">{selectedApp.max_token_duration / 3600}h</Text>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text fw={500}>Created:</Text>
|
||||
<Text size="sm">{dayjs(selectedApp.created_at).format('MMM DD, YYYY HH:mm')}</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,29 +1,14 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Text,
|
||||
Table,
|
||||
import {
|
||||
DataTable,
|
||||
TableColumn,
|
||||
Badge,
|
||||
ActionIcon,
|
||||
TextInput,
|
||||
Select,
|
||||
Pagination,
|
||||
Stack,
|
||||
LoadingOverlay,
|
||||
Avatar,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconPlus,
|
||||
IconEdit,
|
||||
IconTrash,
|
||||
IconSearch,
|
||||
IconUser,
|
||||
IconMail,
|
||||
} from '@tabler/icons-react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { modals } from '@mantine/modals';
|
||||
Group,
|
||||
Text,
|
||||
Stack
|
||||
} from '@skybridge/web-components';
|
||||
import { Avatar } from '@mantine/core';
|
||||
import { IconUser, IconMail } from '@tabler/icons-react';
|
||||
import UserSidebar from './UserSidebar';
|
||||
import { userService } from '../services/userService';
|
||||
import { User, UserStatus, UserRole, ListUsersRequest } from '../types/user';
|
||||
@ -31,9 +16,7 @@ import { User, UserStatus, UserRole, ListUsersRequest } from '../types/user';
|
||||
const UserManagement: React.FC = () => {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
const [roleFilter, setRoleFilter] = useState<string | null>(null);
|
||||
const [filters, setFilters] = useState({});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalUsers, setTotalUsers] = useState(0);
|
||||
const [userSidebarOpened, setUserSidebarOpened] = useState(false);
|
||||
@ -41,13 +24,13 @@ const UserManagement: React.FC = () => {
|
||||
|
||||
const pageSize = 10;
|
||||
|
||||
const loadUsers = async (page: number = currentPage) => {
|
||||
const loadUsers = async (page: number = currentPage, newFilters = filters) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const request: ListUsersRequest = {
|
||||
search: searchTerm || undefined,
|
||||
status: statusFilter as UserStatus || undefined,
|
||||
role: roleFilter as UserRole || undefined,
|
||||
search: newFilters.search || undefined,
|
||||
status: newFilters.status as UserStatus || undefined,
|
||||
role: newFilters.role as UserRole || undefined,
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
order_by: 'created_at',
|
||||
@ -60,95 +43,108 @@ const UserManagement: React.FC = () => {
|
||||
setCurrentPage(page);
|
||||
} catch (error) {
|
||||
console.error('Failed to load users:', error);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to load users',
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers(1);
|
||||
}, [searchTerm, statusFilter, roleFilter]);
|
||||
loadUsers(1, filters);
|
||||
}, [filters]);
|
||||
|
||||
const handleCreateUser = () => {
|
||||
const handleAdd = () => {
|
||||
setEditingUser(null);
|
||||
setUserSidebarOpened(true);
|
||||
};
|
||||
|
||||
const handleEditUser = (user: User) => {
|
||||
const handleEdit = (user: User) => {
|
||||
setEditingUser(user);
|
||||
setUserSidebarOpened(true);
|
||||
};
|
||||
|
||||
const handleDeleteUser = (user: User) => {
|
||||
modals.openConfirmModal({
|
||||
title: 'Delete User',
|
||||
children: (
|
||||
<Text size="sm">
|
||||
Are you sure you want to delete {user.first_name} {user.last_name}? This action cannot be undone.
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: 'Delete', cancel: 'Cancel' },
|
||||
confirmProps: { color: 'red' },
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await userService.deleteUser(user.id);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'User deleted successfully',
|
||||
color: 'green',
|
||||
});
|
||||
loadUsers();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete user:', error);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to delete user',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleUserSidebarSuccess = () => {
|
||||
const handleDelete = async (user: User) => {
|
||||
await userService.deleteUser(user.id);
|
||||
loadUsers();
|
||||
};
|
||||
|
||||
const handleUserSidebarClose = () => {
|
||||
const handleSuccess = () => {
|
||||
setUserSidebarOpened(false);
|
||||
setEditingUser(null);
|
||||
loadUsers();
|
||||
};
|
||||
|
||||
const getStatusColor = (status: UserStatus): string => {
|
||||
switch (status) {
|
||||
case 'active': return 'green';
|
||||
case 'inactive': return 'gray';
|
||||
case 'suspended': return 'red';
|
||||
case 'pending': return 'yellow';
|
||||
default: return 'gray';
|
||||
}
|
||||
const handleFiltersChange = (newFilters) => {
|
||||
setFilters(newFilters);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const getRoleColor = (role: UserRole): string => {
|
||||
switch (role) {
|
||||
case 'admin': return 'red';
|
||||
case 'moderator': return 'orange';
|
||||
case 'user': return 'blue';
|
||||
case 'viewer': return 'gray';
|
||||
default: return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(totalUsers / pageSize);
|
||||
const columns: TableColumn[] = [
|
||||
{
|
||||
key: 'user',
|
||||
label: 'User',
|
||||
render: (_, user: User) => (
|
||||
<Group gap="sm">
|
||||
<Avatar
|
||||
src={user.avatar || null}
|
||||
radius="sm"
|
||||
size={32}
|
||||
>
|
||||
<IconUser size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{user.display_name || `${user.first_name} ${user.last_name}`}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{user.first_name} {user.last_name}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: 'Email',
|
||||
render: (value) => (
|
||||
<Group gap="xs">
|
||||
<IconMail size={14} />
|
||||
<Text size="sm">{value}</Text>
|
||||
</Group>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
label: 'Role',
|
||||
render: (value) => {
|
||||
const roleColors = {
|
||||
admin: 'red',
|
||||
moderator: 'orange',
|
||||
user: 'blue',
|
||||
viewer: 'gray'
|
||||
};
|
||||
return (
|
||||
<Badge color={roleColors[value] || 'blue'} size="sm">
|
||||
{value}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status'
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
label: 'Created',
|
||||
render: (value) => (
|
||||
<Text size="sm">
|
||||
{new Date(value).toLocaleDateString()}
|
||||
</Text>
|
||||
)
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Main user management interface */}
|
||||
<Stack
|
||||
gap="md"
|
||||
style={{
|
||||
@ -156,160 +152,32 @@ const UserManagement: React.FC = () => {
|
||||
marginRight: userSidebarOpened ? '400px' : '0',
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={handleCreateUser}>
|
||||
Add User
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Paper p="md" withBorder>
|
||||
<Group justify="space-between" mb="md">
|
||||
<Group gap="sm">
|
||||
<TextInput
|
||||
placeholder="Search users..."
|
||||
leftSection={<IconSearch size={16} />}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.currentTarget.value)}
|
||||
style={{ minWidth: 250 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Filter by status"
|
||||
data={[
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'inactive', label: 'Inactive' },
|
||||
{ value: 'suspended', label: 'Suspended' },
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
]}
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
clearable
|
||||
/>
|
||||
<Select
|
||||
placeholder="Filter by role"
|
||||
data={[
|
||||
{ value: 'admin', label: 'Admin' },
|
||||
{ value: 'moderator', label: 'Moderator' },
|
||||
{ value: 'user', label: 'User' },
|
||||
{ value: 'viewer', label: 'Viewer' },
|
||||
]}
|
||||
value={roleFilter}
|
||||
onChange={setRoleFilter}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{totalUsers} users found
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<div style={{ position: 'relative' }}>
|
||||
<LoadingOverlay visible={loading} />
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>User</Table.Th>
|
||||
<Table.Th>Email</Table.Th>
|
||||
<Table.Th>Role</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Created</Table.Th>
|
||||
<Table.Th>Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{users.map((user) => (
|
||||
<Table.Tr key={user.id}>
|
||||
<Table.Td>
|
||||
<Group gap="sm">
|
||||
<Avatar
|
||||
src={user.avatar || null}
|
||||
radius="sm"
|
||||
size={32}
|
||||
>
|
||||
<IconUser size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{user.display_name || `${user.first_name} ${user.last_name}`}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{user.first_name} {user.last_name}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<IconMail size={14} />
|
||||
<Text size="sm">{user.email}</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={getRoleColor(user.role)} variant="light">
|
||||
{user.role}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={getStatusColor(user.status)} variant="light">
|
||||
{user.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">
|
||||
{new Date(user.created_at).toLocaleDateString()}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
size="sm"
|
||||
onClick={() => handleEditUser(user)}
|
||||
>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteUser(user)}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
{users.length === 0 && !loading && (
|
||||
<Text ta="center" py="xl" c="dimmed">
|
||||
No users found
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Group justify="center" mt="md">
|
||||
<Pagination
|
||||
value={currentPage}
|
||||
onChange={loadUsers}
|
||||
total={totalPages}
|
||||
size="sm"
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
{/* User sidebar */}
|
||||
<DataTable
|
||||
data={users}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
title="User Management"
|
||||
total={totalUsers}
|
||||
page={currentPage}
|
||||
pageSize={pageSize}
|
||||
onPageChange={loadUsers}
|
||||
searchable
|
||||
filters={filters}
|
||||
onFiltersChange={handleFiltersChange}
|
||||
onAdd={handleAdd}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onRefresh={() => loadUsers()}
|
||||
emptyMessage="No users found"
|
||||
/>
|
||||
|
||||
<UserSidebar
|
||||
opened={userSidebarOpened}
|
||||
onClose={handleUserSidebarClose}
|
||||
onSuccess={handleUserSidebarSuccess}
|
||||
onClose={() => setUserSidebarOpened(false)}
|
||||
onSuccess={handleSuccess}
|
||||
editUser={editingUser}
|
||||
/>
|
||||
</>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -1,21 +1,10 @@
|
||||
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 React from 'react';
|
||||
import {
|
||||
FormSidebar,
|
||||
FormField
|
||||
} from '@skybridge/web-components';
|
||||
import { userService } from '../services/userService';
|
||||
import { User, CreateUserRequest, UpdateUserRequest, UserRole, UserStatus } from '../types/user';
|
||||
import { User } from '../types/user';
|
||||
|
||||
interface UserSidebarProps {
|
||||
opened: boolean;
|
||||
@ -30,225 +19,91 @@ const UserSidebar: React.FC<UserSidebarProps> = ({
|
||||
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,
|
||||
const fields: FormField[] = [
|
||||
{
|
||||
name: 'first_name',
|
||||
label: 'First Name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: 'Enter first name',
|
||||
},
|
||||
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),
|
||||
{
|
||||
name: 'last_name',
|
||||
label: 'Last Name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: 'Enter last name',
|
||||
},
|
||||
});
|
||||
{
|
||||
name: 'display_name',
|
||||
label: 'Display Name',
|
||||
type: 'text',
|
||||
required: false,
|
||||
placeholder: 'Enter display name (optional)',
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
label: 'Email',
|
||||
type: 'email',
|
||||
required: true,
|
||||
placeholder: 'Enter email address',
|
||||
validation: { email: true },
|
||||
},
|
||||
{
|
||||
name: 'avatar',
|
||||
label: 'Avatar URL',
|
||||
type: 'text',
|
||||
required: false,
|
||||
placeholder: 'Enter avatar URL (optional)',
|
||||
},
|
||||
{
|
||||
name: 'role',
|
||||
label: 'Role',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'admin', label: 'Admin' },
|
||||
{ value: 'moderator', label: 'Moderator' },
|
||||
{ value: 'user', label: 'User' },
|
||||
{ value: 'viewer', label: 'Viewer' },
|
||||
],
|
||||
defaultValue: 'user',
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
label: 'Status',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'inactive', label: 'Inactive' },
|
||||
{ value: 'suspended', label: 'Suspended' },
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
],
|
||||
defaultValue: 'pending',
|
||||
},
|
||||
];
|
||||
|
||||
// Update form values when editUser changes
|
||||
useEffect(() => {
|
||||
const handleSubmit = async (values: any) => {
|
||||
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,
|
||||
});
|
||||
await userService.updateUser(editUser.id, values);
|
||||
} 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',
|
||||
});
|
||||
await userService.createUser(values);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
<FormSidebar
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
onSuccess={onSuccess}
|
||||
title="User"
|
||||
editMode={!!editUser}
|
||||
editItem={editUser}
|
||||
fields={fields}
|
||||
onSubmit={handleSubmit}
|
||||
width={400}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
2
web/dist/main.js
vendored
2
web/dist/main.js
vendored
File diff suppressed because one or more lines are too long
@ -14,7 +14,10 @@
|
||||
"@mantine/core": "^7.0.0",
|
||||
"@mantine/hooks": "^7.0.0",
|
||||
"@mantine/notifications": "^7.0.0",
|
||||
"@tabler/icons-react": "^2.40.0"
|
||||
"@mantine/form": "^7.0.0",
|
||||
"@mantine/modals": "^7.0.0",
|
||||
"@tabler/icons-react": "^2.40.0",
|
||||
"@skybridge/web-components": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.12",
|
||||
|
||||
@ -7,8 +7,8 @@ import {
|
||||
Group,
|
||||
Stack,
|
||||
Title,
|
||||
Badge,
|
||||
} from '@mantine/core';
|
||||
import { Badge } from '@skybridge/web-components';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
IconStar,
|
||||
|
||||
Reference in New Issue
Block a user